Repository: conan-io/conan
Pull request #17559 (2025-01-10 12:01:43)

Add cycloneDX as a Conan tool and implement subgraph for conanfile
Changelog: (Feature | Fix | Bugfix): Add cycloneDX as a Conan tool and implement subgraph for conanfile
Docs: https://github.com/conan-io/docs/pull/XXXX




Patch:
diff --git a/conan/api/conan_api.py b/conan/api/conan_api.py
index 091af879f..7b6fd14fe 100644
--- a/conan/api/conan_api.py
+++ b/conan/api/conan_api.py
@@ -6,7 +6,6 @@ from conan.api.subapi.command import CommandAPI
 from conan.api.subapi.local import LocalAPI
 from conan.api.subapi.lockfile import LockfileAPI
 from conan.api.subapi.workspace import WorkspaceAPI
-from conan import conan_version
 from conan.api.subapi.config import ConfigAPI
 from conan.api.subapi.download import DownloadAPI
 from conan.api.subapi.export import ExportAPI
@@ -19,9 +18,9 @@ from conan.api.subapi.remotes import RemotesAPI
 from conan.api.subapi.remove import RemoveAPI
 from conan.api.subapi.search import SearchAPI
 from conan.api.subapi.upload import UploadAPI
-from conans.client.migrations import ClientMigrator
 from conan.errors import ConanException
 from conan.internal.paths import get_conan_user_home
+from conans.client.migrations import ClientMigrator
 from conan.internal.model.version_range import validate_conan_version
 
 
@@ -36,12 +35,11 @@ class ConanAPI:
         self.workspace = WorkspaceAPI(self)
         self.cache_folder = self.workspace.home_folder() or cache_folder or get_conan_user_home()
         self.home_folder = self.cache_folder  # Lets call it home, deprecate "cache"
+        self.migrate()
 
-        # Migration system
-        migrator = ClientMigrator(self.cache_folder, conan_version)
-        migrator.migrate()
-
+        # This API is depended upon by the subsequent ones, it should be initialized first
         self.config = ConfigAPI(self)
+
         self.remotes = RemotesAPI(self)
         self.command = CommandAPI(self)
         # Search recipes by wildcard and packages filtering by configuration
@@ -60,6 +58,24 @@ class ConanAPI:
         self.lockfile = LockfileAPI(self)
         self.local = LocalAPI(self)
 
-        required_range_new = self.config.global_conf.get("core:required_conan_version")
-        if required_range_new:
-            validate_conan_version(required_range_new)
+        _check_conan_version(self)
+
+    def reinit(self):
+        self.config.reinit()
+        self.remotes.reinit()
+        self.local.reinit()
+
+        _check_conan_version(self)
+
+    def migrate(self):
+        # Migration system
+        # TODO: A prettier refactoring of migrators would be nice
+        from conan import conan_version
+        migrator = ClientMigrator(self.cache_folder, conan_version)
+        migrator.migrate()
+
+
+def _check_conan_version(conan_api):
+    required_range_new = conan_api.config.global_conf.get("core:required_conan_version")
+    if required_range_new:
+        validate_conan_version(required_range_new)
diff --git a/conan/api/subapi/config.py b/conan/api/subapi/config.py
index 6070802e3..0267766a1 100644
--- a/conan/api/subapi/config.py
+++ b/conan/api/subapi/config.py
@@ -16,11 +16,11 @@ from conans.client.graph.graph import CONTEXT_HOST, RECIPE_VIRTUAL, Node
 from conans.client.graph.graph_builder import DepsGraphBuilder
 from conans.client.graph.profile_node_definer import consumer_definer
 from conan.errors import ConanException
-from conan.internal.model.conf import ConfDefinition, BUILT_IN_CONFS
+from conan.internal.model.conf import ConfDefinition, BUILT_IN_CONFS, CORE_CONF_PATTERN
 from conan.internal.model.pkg_type import PackageType
 from conan.api.model import RecipeReference
 from conan.internal.model.settings import Settings
-from conans.util.files import load, save
+from conans.util.files import load, save, rmdir, remove
 
 
 class ConfigAPI:
@@ -28,6 +28,7 @@ class ConfigAPI:
     def __init__(self, conan_api):
         self.conan_api = conan_api
         self._new_config = None
+        self._cli_core_confs = None
 
     def home(self):
         return self.conan_api.cache_folder
@@ -40,6 +41,7 @@ class ConfigAPI:
         requester = self.conan_api.remotes.requester
         configuration_install(cache_folder, requester, path_or_url, verify_ssl, config_type=config_type, args=args,
                               source_folder=source_folder, target_folder=target_folder)
+        self.conan_api.reinit()
 
     def install_pkg(self, ref, lockfile=None, force=False, remotes=None, profile=None):
         ConanOutput().warning("The 'conan config install-pkg' is experimental",
@@ -100,6 +102,7 @@ class ConfigAPI:
         config_versions = {ref.split("/", 1)[0]: ref for ref in config_versions}
         config_versions[pkg.pref.ref.name] = pkg.pref.repr_notime()
         save(config_version_file, json.dumps({"config_version": list(config_versions.values())}))
+        self.conan_api.reinit()
         return pkg.pref
 
     def get(self, name, default=None, check_type=None):
@@ -114,11 +117,19 @@ class ConfigAPI:
         configuration defined with the new syntax as in profiles, this config will be composed
         to the profile ones and passed to the conanfiles.conf, which can be passed to collaborators
         """
+        # Lazy loading
         if self._new_config is None:
-            cache_folder = self.conan_api.cache_folder
-            self._new_config = self.load_config(cache_folder)
+            self._new_config = ConfDefinition()
+            self._populate_global_conf()
         return self._new_config
 
+    def _populate_global_conf(self):
+        cache_folder = self.conan_api.cache_folder
+        new_config = self.load_config(cache_folder)
+        self._new_config.update_conf_definition(new_config)
+        if self._cli_core_confs is not None:
+            self._new_config.update_conf_definition(self._cli_core_confs)
+
     @staticmethod
     def load_config(home_folder):
         # Do not document yet, keep it private
@@ -191,3 +202,35 @@ class ConfigAPI:
             appending_recursive_dict_update(settings, settings_user)
 
         return Settings(settings)
+
+    def clean(self):
+        contents = os.listdir(self.home())
+        packages_folder = self.global_conf.get("core.cache:storage_path") or os.path.join(self.home(), "p")
+        for content in contents:
+            content_path = os.path.join(self.home(), content)
+            if content_path == packages_folder or content == "version.txt":
+                continue
+            ConanOutput().debug(f"Removing {content_path}")
+            if os.path.isdir(content_path):
+                rmdir(content_path)
+            else:
+                remove(content_path)
+        self.conan_api.reinit()
+        # CHECK: This also generates a remotes.json that is not there after a conan profile show?
+        self.conan_api.migrate()
+
+    def set_core_confs(self, core_confs):
+        confs = ConfDefinition()
+        for c in core_confs:
+            if not CORE_CONF_PATTERN.match(c):
+                raise ConanException(f"Only core. values are allowed in --core-conf. Got {c}")
+        confs.loads("\n".join(core_confs))
+        confs.validate()
+        self._cli_core_confs = confs
+        # Last but not least, apply the new configuration
+        self.conan_api.reinit()
+
+    def reinit(self):
+        if self._new_config is not None:
+            self._new_config.clear()
+            self._populate_global_conf()
diff --git a/conan/api/subapi/local.py b/conan/api/subapi/local.py
index 9a7ae9d9f..71dc48677 100644
--- a/conan/api/subapi/local.py
+++ b/conan/api/subapi/local.py
@@ -118,3 +118,6 @@ class LocalAPI:
         conanfile = app.loader.load_named(conanfile_path, name=name, version=version, user=user,
                                           channel=channel, remotes=remotes, graph_lock=lockfile)
         return conanfile
+
+    def reinit(self):
+        self.editable_packages = EditablePackages(self._conan_api.home_folder)
diff --git a/conan/api/subapi/remotes.py b/conan/api/subapi/remotes.py
index 9e6ae90b1..ebc17c4d9 100644
--- a/conan/api/subapi/remotes.py
+++ b/conan/api/subapi/remotes.py
@@ -35,6 +35,9 @@ class RemotesAPI:
         # Wraps an http_requester to inject proxies, certs, etc
         self._requester = ConanRequester(self.conan_api.config.global_conf, self.conan_api.cache_folder)
 
+    def reinit(self):
+        self._requester = ConanRequester(self.conan_api.config.global_conf, self.conan_api.cache_folder)
+
     def list(self, pattern=None, only_enabled=True):
         """
         Obtain a list of ``Remote`` objects matching the pattern.
diff --git a/conan/cli/command.py b/conan/cli/command.py
index 5447203b3..4a7e80971 100644
--- a/conan/cli/command.py
+++ b/conan/cli/command.py
@@ -4,7 +4,6 @@ from contextlib import redirect_stdout
 
 from conan.api.output import ConanOutput
 from conan.errors import ConanException
-from conan.internal.model.conf import CORE_CONF_PATTERN
 
 
 class OnceArgument(argparse.Action):
@@ -126,14 +125,7 @@ class ConanArgumentParser(argparse.ArgumentParser):
             ConanOutput().error("The --lockfile-packages arg is private and shouldn't be used")
         global_conf = self._conan_api.config.global_conf
         if args.core_conf:
-            from conan.internal.model.conf import ConfDefinition
-            confs = ConfDefinition()
-            for c in args.core_conf:
-                if not CORE_CONF_PATTERN.match(c):
-                    raise ConanException(f"Only core. values are allowed in --core-conf. Got {c}")
-            confs.loads("\n".join(args.core_conf))
-            confs.validate()
-            global_conf.update_conf_definition(confs)
+            self._conan_api.config.set_core_confs(args.core_conf)
 
         # TODO: This might be even better moved to the ConanAPI so users without doing custom
         #  commands can benefit from it
diff --git a/conan/cli/commands/config.py b/conan/cli/commands/config.py
index 9aeaa2e5f..8454d1401 100644
--- a/conan/cli/commands/config.py
+++ b/conan/cli/commands/config.py
@@ -1,3 +1,4 @@
+from conan.api.input import UserInput
 from conan.api.model import Remote
 from conan.api.output import cli_out_write
 from conan.cli.command import conan_command, conan_subcommand, OnceArgument
@@ -132,3 +133,12 @@ def config_show(conan_api, parser, subparser, *args):
     args = parser.parse_args(*args)
 
     return conan_api.config.show(args.pattern)
+
+
+@conan_subcommand()
+def config_clean(conan_api, parser, subparser, *args):
+    """
+    Clean the configuration files in the Conan home folder. (Keeping installed packages)
+    """
+    parser.parse_args(*args)
+    conan_api.config.clean()
diff --git a/conan/internal/model/conan_file.py b/conan/internal/model/conan_file.py
index 3af81e440..58fb90754 100644
--- a/conan/internal/model/conan_file.py
+++ b/conan/internal/model/conan_file.py
@@ -189,6 +189,10 @@ class ConanFile:
     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/conan/internal/model/conf.py b/conan/internal/model/conf.py
index ed3cf8ed7..b5971a091 100644
--- a/conan/internal/model/conf.py
+++ b/conan/internal/model/conf.py
@@ -706,3 +706,6 @@ class ConfDefinition:
     def validate(self):
         for conf in self._pattern_confs.values():
             conf.validate()
+
+    def clear(self):
+        self._pattern_confs.clear()
diff --git a/conan/internal/model/requires.py b/conan/internal/model/requires.py
index 2c3d03451..f5e5aae6f 100644
--- a/conan/internal/model/requires.py
+++ b/conan/internal/model/requires.py
@@ -15,6 +15,7 @@ class Requirement:
         # * prevents the usage of more positional parameters, always ref + **kwargs
         # By default this is a generic library requirement
         self.ref = ref
+        self._required_ref = ref  # Store the original reference
         self._headers = headers  # This dependent node has headers that must be -I<headers-path>
         self._libs = libs
         self._build = build  # This dependent node is a build tool that runs at build time only
@@ -152,7 +153,8 @@ class Requirement:
         return "{}, Traits: {}".format(self.ref, traits)
 
     def serialize(self):
-        result = {"ref": str(self.ref)}
+        result = {"ref": str(self.ref),
+                  "require": str(self._required_ref)}
         serializable = ("run", "libs", "skip", "test", "force", "direct", "build",
                         "transitive_headers", "transitive_libs", "headers",
                         "package_id_mode", "visible")
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 6cbb550cd..433c30e2d 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -224,7 +224,8 @@ def rename(conanfile, src, dst):
     :param dst: Path to be renamed to.
     """
 
-    # FIXME: This function has been copied from legacy. Needs to fix: which() call and wrap subprocess call.
+    # FIXME: This function has been copied from legacy. Needs to fix: which()
+    # call and wrap subprocess call.
     if os.path.exists(dst):
         raise ConanException("rename {} to {} failed, dst exists.".format(src, dst))
 
@@ -310,7 +311,7 @@ def unzip(conanfile, filename, destination=".", keep_permissions=False, pattern=
         if pattern:
             zip_info = [zi for zi in zip_info if fnmatch(zi.filename, pattern)]
         if strip_root:
-            names = [n.replace("\\", "/") for n in z.namelist()]
+            names = [zi.filename.replace("\\", "/") for zi in zip_info]
             common_folder = os.path.commonprefix(names).split("/", 1)[0]
             if not common_folder and len(names) > 1:
                 raise ConanException("The zip file contains more than 1 folder in the root")
@@ -363,8 +364,11 @@ def untargz(filename, destination=".", pattern=None, strip_root=False, extract_f
         else:
             members = tarredgzippedFile.getmembers()
 
+            if pattern:
+                members = list(filter(lambda m: fnmatch(m.name, pattern),
+                                      tarredgzippedFile.getmembers()))
             if strip_root:
-                names = [n.replace("\\", "/") for n in tarredgzippedFile.getnames()]
+                names = [member.name.replace("\\", "/") for member in members]
                 common_folder = os.path.commonprefix(names).split("/", 1)[0]
                 if not common_folder and len(names) > 1:
                     raise ConanException("The tgz file contains more than 1 folder in the root")
@@ -381,9 +385,6 @@ def untargz(filename, destination=".", pattern=None, strip_root=False, extract_f
                         linkpath = member.linkpath.replace("\\", "/")
                         member.linkpath = linkpath.split("/", 1)[1]
                         member.linkname = member.linkpath
-            if pattern:
-                members = list(filter(lambda m: fnmatch(m.name, pattern),
-                                      tarredgzippedFile.getmembers()))
             tarredgzippedFile.extractall(destination, members=members)
 
 
diff --git a/conan/tools/sbom/__init__.py b/conan/tools/sbom/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/conan/tools/sbom/cyclonedx.py b/conan/tools/sbom/cyclonedx.py
new file mode 100644
index 000000000..a875833df
--- /dev/null
+++ b/conan/tools/sbom/cyclonedx.py
@@ -0,0 +1,76 @@
+
+def cyclonedx_1_4(graph, **kwargs):
+    """
+    (Experimental) Generate cyclone 1.4 sbom with json format
+    """
+    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,
+            "purl": f"pkg:conan/{c.name}@{c.ref.version}",
+            "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
diff --git a/conans/client/graph/compatibility.py b/conans/client/graph/compatibility.py
index 85e615502..d69e64eff 100644
--- a/conans/client/graph/compatibility.py
+++ b/conans/client/graph/compatibility.py
@@ -15,9 +15,40 @@ from cppstd_compat import cppstd_compat
 
 
 def compatibility(conanfile):
-    configs = cppstd_compat(conanfile)
-    # TODO: Append more configurations for your custom compatibility rules
-    return configs
+    # By default, different compiler.cppstd are compatible
+    # factors is a list of lists
+    factors = cppstd_compat(conanfile)
+
+    # MSVC 194->193 fallback compatibility
+    compiler = conanfile.settings.get_safe("compiler")
+    compiler_version = conanfile.settings.get_safe("compiler.version")
+    if compiler == "msvc":
+        msvc_fallback = {"194": "193"}.get(compiler_version)
+        if msvc_fallback:
+            factors.append([{"compiler.version": msvc_fallback}])
+
+    # Append more factors for your custom compatibility rules here
+
+    # Combine factors to compute all possible configurations
+    combinations = _factors_combinations(factors)
+    # Final compatibility settings combinations to check
+    return [{"settings": [(k, v) for k, v in comb.items()]} for comb in combinations]
+
+
+def _factors_combinations(factors):
+    combinations = []
+    for factor in factors:
+        if not combinations:
+            combinations = factor
+            continue
+        new_combinations = []
+        for comb in combinations:
+            for f in factor:
+                new_comb = comb.copy()
+                new_comb.update(f)
+                new_combinations.append(new_comb)
+        combinations.extend(new_combinations)
+    return combinations
 """
 
 
@@ -51,29 +82,7 @@ def cppstd_compat(conanfile):
             conanfile.output.warning(f'No cstd compatibility defined for compiler "{compiler}"')
         else:
             factors.append([{"compiler.cstd": v} for v in cstd_possible_values if v != cstd])
-
-    if compiler == "msvc":
-        msvc_fallback = {"194": "193"}.get(compiler_version)
-        if msvc_fallback:
-            factors.append([{"compiler.version": msvc_fallback}])
-
-    combinations = []
-    for factor in factors:
-        if not combinations:
-            combinations = factor
-            continue
-        new_combinations = []
-        for comb in combinations:
-            for f in factor:
-                new_comb = comb.copy()
-                new_comb.update(f)
-                new_combinations.append(new_comb)
-        combinations.extend(new_combinations)
-
-    ret = []
-    for comb in combinations:
-        ret.append({"settings": [(k, v) for k, v in comb.items()]})
-    return ret
+    return factors
 """
 
 
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index 2b2ef7bec..b5037c271 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -70,6 +70,23 @@ class Node(object):
         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
diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py
index 970d95f95..51ac37afa 100644
--- a/conans/client/graph/graph_builder.py
+++ b/conans/client/graph/graph_builder.py
@@ -318,6 +318,7 @@ class DepsGraphBuilder(object):
                 continue
             if pattern.channel != "*" and pattern.channel != require.ref.channel:
                 continue
+            require._required_ref = require.ref.copy()  # Save the original ref before replacing
             original_require = repr(require.ref)
             if alternative_ref.version != "*":
                 require.ref.version = alternative_ref.version
diff --git a/conans/client/migrations.py b/conans/client/migrations.py
index 82997ba4e..026ef8b81 100644
--- a/conans/client/migrations.py
+++ b/conans/client/migrations.py
@@ -3,7 +3,6 @@ import sqlite3
 import textwrap
 
 from conan.api.output import ConanOutput
-from conan.api.subapi.config import ConfigAPI
 from conan.internal.default_settings import migrate_settings_file
 from conans.migrations import Migrator
 from conans.util.dates import timestamp_now
@@ -76,6 +75,7 @@ def _migrate_default_compatibility(cache_folder):
 
 
 def _migrate_pkg_db_lru(cache_folder, old_version):
+    from conan.api.subapi.config import ConfigAPI
     config = ConfigAPI.load_config(cache_folder)
     storage = config.get("core.cache:storage_path") or os.path.join(cache_folder, "p")
     db_filename = os.path.join(storage, 'cache.sqlite3')


Test patch:
diff --git a/test/functional/sbom/test_cyclonedx.py b/test/functional/sbom/test_cyclonedx.py
new file mode 100644
index 000000000..8aafd1945
--- /dev/null
+++ b/test/functional/sbom/test_cyclonedx.py
@@ -0,0 +1,138 @@
+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
+
+# Using the sbom tool with "conan create"
+sbom_hook_post_package = """
+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 post_package(conanfile):
+    sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
+    metadata_folder = conanfile.package_metadata_folder
+    file_name = "sbom.cdx.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}")
+"""
+
+@pytest.fixture()
+def hook_setup_post_package():
+    tc = TestClient()
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_package)
+    return tc
+
+@pytest.fixture()
+def hook_setup_post_package_tl(transitive_libraries):
+    tc = transitive_libraries
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_package)
+    return tc
+
+
+
+def test_sbom_generation_create(hook_setup_post_package_tl):
+    tc = hook_setup_post_package_tl
+    tc.run("new cmake_lib -d name=bar -d version=1.0 -d requires=engine/1.0 -f")
+    # bar -> engine/1.0 -> matrix/1.0
+    tc.run("create . --build=missing")
+    bar_layout = tc.created_layout()
+    assert os.path.exists(os.path.join(bar_layout.metadata(), "sbom.cdx.json"))
+
+def test_sbom_generation_skipped_dependencies(hook_setup_post_package):
+    tc = hook_setup_post_package
+    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.metadata(), "sbom.cdx.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
+
+
+# Using the sbom tool with "conan install"
+sbom_hook_post_generate = """
+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 post_generate(conanfile):
+    sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
+    generators_folder = conanfile.generators_folder
+    file_name = "sbom.cdx.json"
+    os.mkdir(os.path.join(generators_folder, "sbom"))
+    with open(os.path.join(generators_folder, "sbom", file_name), 'w') as f:
+        json.dump(sbom_cyclonedx_1_4, f, indent=4)
+    ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.generators_folder}")
+"""
+
+@pytest.fixture()
+def hook_setup_post_generate():
+    tc = TestClient()
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_generate)
+    return tc
+
+def test_sbom_generation_install_requires(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    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, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    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, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path_consumer(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    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, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path_txt(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    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, "sbom", "sbom.cdx.json"))
diff --git a/test/integration/command/config_test.py b/test/integration/command/config_test.py
index 17c552ec4..c84ea3e36 100644
--- a/test/integration/command/config_test.py
+++ b/test/integration/command/config_test.py
@@ -2,7 +2,10 @@ import json
 import os
 import textwrap
 
+import pytest
+
 from conan.api.conan_api import ConanAPI
+from conan.test.assets.genconanfile import GenConanfile
 from conan.internal.model.conf import BUILT_IN_CONFS
 from conan.test.utils.test_files import temp_folder
 from conan.test.utils.tools import TestClient
@@ -201,3 +204,67 @@ def test_config_show():
     tc.run("config show zlib/*:foo")
     assert "zlib/*:user.mycategory:foo" in tc.out
     assert "zlib/*:user.myothercategory:foo" in tc.out
+
+
+@pytest.mark.parametrize("storage_path", [None, "p", "../foo"])
+def test_config_clean(storage_path):
+    tc = TestClient(light=True)
+    absolut_storage_path = os.path.abspath(os.path.join(tc.current_folder, storage_path)) if storage_path else os.path.join(tc.cache_folder, "p")
+
+    storage = f"core.cache:storage_path={storage_path}" if storage_path else ""
+    tc.save_home({"global.conf": f"core.upload:retry=7\n{storage}",
+                  "extensions/compatibility/mycomp.py": "",
+                  "extensions/commands/cmd_foo.py": "",
+                  })
+
+    tc.run("profile detect --name=foo")
+    tc.run("remote add bar http://fakeurl")
+
+    tc.save({"conanfile.py": GenConanfile("pkg", "0.1")})
+    tc.run("create .")
+
+    assert os.path.exists(absolut_storage_path)
+
+    tc.run("config clean")
+    tc.run("profile list")
+    assert "foo" not in tc.out
+    tc.run("remote list")
+    assert "bar" not in tc.out
+    tc.run("config show core.upload:retry")
+    assert "7" not in tc.out
+    assert not os.path.exists(os.path.join(tc.cache_folder, "extensions"))
+    assert os.path.exists(absolut_storage_path)
+
+
+def test_config_reinit():
+    custom_global_conf = "core.upload:retry=7"
+    global_conf_folder = temp_folder()
+    with open(os.path.join(global_conf_folder, "global.conf"), "w") as f:
+        f.write(custom_global_conf)
+
+    cache_folder = temp_folder()
+    conan_api = ConanAPI(cache_folder=cache_folder)
+    # Ensure reinitialization does not invalidate references
+    config_api = conan_api.config
+    assert config_api.global_conf.get("core.upload:retry", check_type=int) != 7
+
+    conan_api.config.install(global_conf_folder, verify_ssl=False)
+    # Already has an effect, the config installation reinitializes the config
+    assert config_api.global_conf.get("core.upload:retry", check_type=int) == 7
+
+
+def test_config_reinit_core_conf():
+    tc = TestClient(light=True)
+    tc.save_home({"extensions/commands/cmd_foo.py": textwrap.dedent("""
+        import json
+        from conan.cli.command import conan_command
+        from conan.api.output import ConanOutput
+
+        @conan_command()
+        def foo(conan_api, parser, *args, **kwargs):
+            ''' Foo '''
+            parser.parse_args(*args)
+            ConanOutput().info(f"Retry: {conan_api.config.global_conf.get('core.upload:retry', check_type=int)}")
+    """)})
+    tc.run("foo -cc core.upload:retry=7")
+    assert "Retry: 7" in tc.out
diff --git a/test/integration/command/create_test.py b/test/integration/command/create_test.py
index e68f52946..620d11f51 100644
--- a/test/integration/command/create_test.py
+++ b/test/integration/command/create_test.py
@@ -488,11 +488,11 @@ def test_create_format_json():
         '1': {'ref': 'hello/0.1', 'run': False, 'libs': True, 'skip': False,
               'test': False, 'force': False, 'direct': True, 'build': False,
               'transitive_headers': None, 'transitive_libs': None, 'headers': True,
-              'package_id_mode': None, 'visible': True},
+              'package_id_mode': None, 'visible': True, 'require': 'hello/0.1'},
         '2': {'ref': 'pkg/0.2', 'run': False, 'libs': True, 'skip': False, 'test': False,
               'force': False, 'direct': False, 'build': False, 'transitive_headers': None,
               'transitive_libs': None, 'headers': True, 'package_id_mode': None,
-              'visible': True}
+              'visible': True, 'require': 'pkg/0.2'}
     }
     assert consumer_info["dependencies"] == consumer_deps
     # hello/0.1 pkg information
@@ -505,7 +505,7 @@ def test_create_format_json():
             "ref": "pkg/0.2", "run": False, "libs": True, "skip": False, "test": False,
             "force": False, "direct": True, "build": False, "transitive_headers": None,
             "transitive_libs": None, "headers": True, "package_id_mode": "semver_mode",
-            "visible": True
+            "visible": True, 'require': 'pkg/0.2'
         }
     }
     assert hello_pkg_info["dependencies"] == hello_pkg_info_deps
diff --git a/test/integration/command/info/info_test.py b/test/integration/command/info/info_test.py
index 8f254820a..59765ee41 100644
--- a/test/integration/command/info/info_test.py
+++ b/test/integration/command/info/info_test.py
@@ -176,6 +176,15 @@ class TestJsonOutput:
         graph = json.loads(client.stdout)
         assert graph["graph"]["nodes"]["0"]["settings"]["build_type"] == "Debug"
 
+    def test_json_info_requirements(self):
+        c = TestClient()
+        c.save({"pkg/conanfile.py": GenConanfile("pkg", "0.1"),
+                "app/conanfile.py": GenConanfile().with_require("pkg/[>=0.0 <1.0]")})
+        c.run("create pkg")
+        c.run("graph info app --format=json")
+        graph = json.loads(c.stdout)
+        assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["require"] == "pkg/[>=0.0 <1.0]"
+
 
 class TestAdvancedCliOutput:
     """ Testing more advanced fields output, like SCM or PYTHON-REQUIRES
diff --git a/test/integration/command_v2/test_inspect.py b/test/integration/command_v2/test_inspect.py
index cd7445800..e23ee392a 100644
--- a/test/integration/command_v2/test_inspect.py
+++ b/test/integration/command_v2/test_inspect.py
@@ -136,8 +136,8 @@ def test_requiremens_inspect():
             'options:',
             'options_definitions:',
             'package_type: None',
-            "requires: [{'ref': 'zlib/1.2.13', 'run': False, 'libs': True, 'skip': "
-            "False, 'test': False, 'force': False, 'direct': True, 'build': "
+            "requires: [{'ref': 'zlib/1.2.13', 'require': 'zlib/1.2.13', 'run': False, "
+            "'libs': True, 'skip': False, 'test': False, 'force': False, 'direct': True, 'build': "
             "False, 'transitive_headers': None, 'transitive_libs': None, 'headers': "
             "True, 'package_id_mode': None, 'visible': True}]",
             'revision_mode: hash',
diff --git a/test/integration/graph/test_replace_requires.py b/test/integration/graph/test_replace_requires.py
index 4d6373c82..c27d729c5 100644
--- a/test/integration/graph/test_replace_requires.py
+++ b/test/integration/graph/test_replace_requires.py
@@ -150,6 +150,8 @@ def test_replace_requires_json_format():
     assert "pkg/0.1: pkg/0.2" in c.out  # The replacement happens
     graph = json.loads(c.stdout)
     assert graph["graph"]["replaced_requires"] == {"pkg/0.1": "pkg/0.2"}
+    assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["ref"] == "pkg/0.2"
+    assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["require"] == "pkg/0.1"
 
 
 def test_replace_requires_test_requires():
diff --git a/test/integration/graph/test_subgraph_reports.py b/test/integration/graph/test_subgraph_reports.py
new file mode 100644
index 000000000..597fb6386
--- /dev/null
+++ b/test/integration/graph/test_subgraph_reports.py
@@ -0,0 +1,48 @@
+import json
+import os
+import textwrap
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.tools import TestClient
+from conans.model.recipe_ref import RecipeReference
+from conans.util.files import load
+
+
+def _metadata(c, ref):
+    pref = c.get_latest_package_reference(RecipeReference.loads(ref))
+    return c.get_latest_pkg_layout(pref).metadata()
+from conan.internal.model.lockfile import Lockfile
+def test_subgraph_reports():
+    c = TestClient()
+    subgraph_hook = textwrap.dedent("""\
+        import os, json
+        from conan.tools.files import save
+        from conan.internal.model.lockfile import Lockfile
+        def post_package(conanfile):
+            subgraph = conanfile.subgraph
+            save(conanfile, os.path.join(conanfile.package_metadata_folder, f"conangraph.json"),
+                 json.dumps(subgraph.serialize(), indent=2))
+            save(conanfile, os.path.join(conanfile.package_metadata_folder, f"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(_metadata(c, "app/0.1"), "conangraph.json")))
+    pkg_graph = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conangraph.json")))
+    dep_graph = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "conangraph.json")))
+
+    app_lock = json.loads(load(os.path.join(_metadata(c, "app/0.1"), "conan.lock")))
+    pkg_lock = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conan.lock")))
+    dep_lock = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "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/lockfile/test_graph_overrides.py b/test/integration/lockfile/test_graph_overrides.py
index 17698db54..2212e2989 100644
--- a/test/integration/lockfile/test_graph_overrides.py
+++ b/test/integration/lockfile/test_graph_overrides.py
@@ -35,8 +35,8 @@ def test_overrides_half_diamond(override, force):
     # apply the lockfile to pkgb, should it lock to pkga/0.2
     c.run("graph info pkgb --lockfile=pkgc/conan.lock --format=json")
     dependencies = json.loads(c.stdout)["graph"]["nodes"]["0"]["dependencies"]
-    assert "pkga/0.2" in str(dependencies)
-    assert "pkga/0.1" not in str(dependencies)
+    assert "pkga/0.2" == dependencies["1"]["ref"]
+    assert "pkga/0.1" == dependencies["1"]["require"]
 
 
 @pytest.mark.parametrize("override, force", [(True, False), (False, True)])
@@ -140,9 +140,8 @@ def test_overrides_diamond(override, force):
     c.run("graph info pkgb --lockfile=pkgd/conan.lock --format=json")
     json_graph = json.loads(c.stdout)
     deps = json_graph["graph"]["nodes"]["0"]["dependencies"]
-    assert "pkga/0.3" in str(deps)
-    assert "pkga/0.2" not in str(deps)
-    assert "pkga/0.1" not in str(deps)
+    assert "pkga/0.3" == deps["1"]["ref"]
+    assert "pkga/0.1" == deps["1"]["require"]
     # Redundant assert, but checking "overrides" summary
     overrides = json_graph['graph']["overrides"]
     assert len(overrides) == 1
diff --git a/test/integration/package_id/compatible_test.py b/test/integration/package_id/compatible_test.py
index 5d384b7df..8758d8629 100644
--- a/test/integration/package_id/compatible_test.py
+++ b/test/integration/package_id/compatible_test.py
@@ -443,13 +443,18 @@ class TestNewCompatibility:
                    compiler.runtime=dynamic
                    """)
         tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0").with_setting("compiler"),
-                 "conanfile.py": GenConanfile("app", "1.0").with_require("dep/1.0").with_setting("compiler"),
                  "profile": profile})
 
         tc.run("create dep -pr=profile -s compiler.cppstd=20")
-        tc.run("create . -pr=profile -s compiler.cppstd=17")
+        tc.run("install --requires=dep/1.0 -pr=profile -s compiler.cppstd=17")
         tc.assert_listed_binary({"dep/1.0": ("b6d26a6bc439b25b434113982791edf9cab4d004", "Cache")})
 
+        tc.run("remove * -c")
+        tc.run("create dep -pr=profile -s compiler.version=193 -s compiler.cppstd=20")
+        tc.run("install --requires=dep/1.0 -pr=profile -s compiler.cppstd=17")
+        assert "compiler.cppstd=20, compiler.version=193" in tc.out
+        tc.assert_listed_binary({"dep/1.0": ("535899bb58c3ca7d80a380313d31f4729e735d1c", "Cache")})
+
 
 class TestCompatibleBuild:
     def test_build_compatible(self):
@@ -706,3 +711,58 @@ class TestCompatibleBuild:
             pkga = liba["packages"][0][pkg_index]
             assert pkga["info"]["compatibility_delta"] == {"settings": [["compiler.cppstd", "14"]]}
             assert pkga["build_args"] == "--requires=liba/0.1 --build=compatible:liba/0.1"
+
+
+def test_compatibility_new_setting_forwards_compat():
+    """ This test tries to reflect the following scenario:
+    - User adds a new setting (libc.version in this case)
+    - This setting is forward compatible
+    How is it solved with compatibility.py? Like this:
+    """
+    tc = TestClient()
+    tc.save_home({"settings_user.yml": "libc_version: [1, 2, 3]"})
+    tc.save({"conanfile.py": GenConanfile("dep", "1.0").with_settings("libc_version", "compiler")})
+    # The extra cppstd and compiler versions are for later demonstrations of combinations of settings
+    # The cppstd=17 and compiler.version=193 are used thought until the last 2 install calls
+    tc.run("create . -s=libc_version=2 -s=compiler.cppstd=17")
+    dep_package_id = tc.created_package_id("dep/1.0")
+    tc.run("install --requires=dep/1.0 -s=libc_version=3 -s=compiler.cppstd=17", assert_error=True)
+    # We can't compile, because the dep is not compatible
+    assert "Missing prebuilt package for 'dep/1.0'" in tc.out
+
+    # Let's create a compatibility extensions
+    libc_compat = textwrap.dedent("""
+        from conan.tools.scm import Version
+
+        def libc_compat(conanfile):
+            # Do we have the setting?
+            libc_version = conanfile.settings.get_safe("libc_version")
+            if libc_version is None:
+                return []
+            available_libc_versions = conanfile.settings.libc_version.possible_values()
+            ret = []
+            for possible_libc_version in available_libc_versions:
+                if Version(possible_libc_version) < Version(libc_version):
+                    ret.append({"libc_version": possible_libc_version})
+            return ret
+        """)
+    compat = tc.load_home("extensions/plugins/compatibility/compatibility.py")
+    compat = "from libc_compat import libc_compat\n" + compat
+    compat = compat.replace("# Append more factors for your custom compatibility rules here",
+                            "factors.append(libc_compat(conanfile))")
+    tc.save_home({"extensions/plugins/compatibility/libc_compat.py": libc_compat,
+                  "extensions/plugins/compatibility/compatibility.py": compat})
+
+    # Now we try again, this time app will find the compatible dep with libc_version 2
+    tc.run("install --requires=dep/1.0 -s=libc_version=3 -s=compiler.cppstd=17")
+    assert f"dep/1.0: Found compatible package '{dep_package_id}'" in tc.out
+
+    # And now we try to create the app with libc_version 1, which is still not compatible
+    tc.run("install --requires=dep/1.0 -s=libc_version=1 -s=compiler.cppstd=17", assert_error=True)
+    assert "Missing prebuilt package for 'dep/1.0'" in tc.out
+
+    # Now we try again, this time app will find the compatible dep with libc_version 2
+    # And see how we're also compatible over a different cppstd
+    tc.run("install --requires=dep/1.0 -s=libc_version=3 -s=compiler.cppstd=14")
+    assert f"dep/1.0: Found compatible package '{dep_package_id}': compiler.cppstd=17, " \
+           f"libc_version=2" in tc.out
diff --git a/test/unittests/tools/files/test_zipping.py b/test/unittests/tools/files/test_zipping.py
index 42e89bcee..0c41a494d 100644
--- a/test/unittests/tools/files/test_zipping.py
+++ b/test/unittests/tools/files/test_zipping.py
@@ -1,6 +1,6 @@
-import os
 import zipfile
-from os.path import basename
+import tarfile
+from os.path import join, exists
 
 import pytest
 
@@ -8,6 +8,7 @@ from conan.tools.files import unzip
 from conan.test.utils.mocks import ConanFileMock
 from conan.test.utils.test_files import temp_folder
 from conans.util.files import save
+from conan.errors import ConanException
 
 
 def test_impossible_to_import_untargz():
@@ -15,17 +16,136 @@ def test_impossible_to_import_untargz():
         from conan.tools.files import untargz
 
 
-def test_unzip():
+def create_example_zip(root_file=True, subfolder=False):
     tmp_dir = temp_folder()
-    file_path = os.path.join(tmp_dir, "foo.txt")
-    save(os.path.join(tmp_dir, "foo.txt"), "bar")
-    zf = zipfile.ZipFile(os.path.join(tmp_dir, 'zipfile.zip'), mode='w')
-    zf.write(file_path, basename(file_path))
+    archive = join(tmp_dir, "zipfile.zip")
+    zf = zipfile.ZipFile(archive, mode="w")
+    if root_file:
+        foo_txt = join(tmp_dir, "foo.txt")
+        save(foo_txt, "foo-content")
+        zf.write(foo_txt, "foo.txt")
+    if subfolder:
+        src_bar_txt = join(tmp_dir, "src", "bar.txt")
+        save(src_bar_txt, "bar-content")
+        zf.write(src_bar_txt, join("src", "bar.txt"))
     zf.close()
+    return archive
+
+
+def test_unzip():
+    archive = create_example_zip(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir)
+    assert exists(join(dest_dir, "foo.txt"))
+    assert exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_unzip_with_pattern():
+    archive = create_example_zip(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="foo.txt")
+    assert exists(join(dest_dir, "foo.txt"))
+    assert not exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_unzip_with_strip_root():
+    archive = create_example_zip(root_file=False, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+
+
+def test_unzip_with_strip_root_fails():
+    archive = create_example_zip(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    with pytest.raises(ConanException) as error:
+        unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert "The zip file contains more than 1 folder in the root" in str(error.value)
+
+
+def test_unzip_with_strip_root_and_pattern():
+    archive = create_example_zip(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="src/*", strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+    assert not exists(join(dest_dir, "foo.txt"))
+
+
+def create_example_tar(root_file=True, subfolder=False):
+    tmp_dir = temp_folder()
+    tar_path = join(tmp_dir, "file.tgz")
+    tar = tarfile.open(tar_path, "w:gz")
+    if root_file:
+        foo_txt = join(tmp_dir, "foo.txt")
+        save(foo_txt, "foo-content")
+        tar.add(foo_txt, "foo.txt")
+    if subfolder:
+        src_bar_txt = join(tmp_dir, "src", "bar.txt")
+        save(src_bar_txt, "bar-content")
+        tar.add(src_bar_txt, join("src", "bar.txt"))
+    tar.close()
+    return tar_path
+
+
+def test_untargz():
+    archive = create_example_tar(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir)
+    assert exists(join(dest_dir, "foo.txt"))
+    assert exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_untargz_with_pattern():
+    archive = create_example_tar(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="foo.txt")
+    assert exists(join(dest_dir, "foo.txt"))
+    assert not exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_untargz_with_strip_root():
+    archive = create_example_tar(root_file=False, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+
+
+def test_untargz_with_strip_root_fails():
+    archive = create_example_tar(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    with pytest.raises(ConanException) as error:
+        unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert "The tgz file contains more than 1 folder in the root" in str(error.value)
+
 
+def test_untargz_with_strip_root_and_pattern():
+    archive = create_example_tar(root_file=True, subfolder=True)
     conanfile = ConanFileMock({})
 
     # Unzip and check permissions are kept
     dest_dir = temp_folder()
-    unzip(conanfile, os.path.join(tmp_dir, 'zipfile.zip'), dest_dir)
-    assert os.path.exists(os.path.join(dest_dir, "foo.txt"))
+    unzip(conanfile, archive, dest_dir, pattern="src/*", strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+    assert not exists(join(dest_dir, "foo.txt"))