Repository: osbuild/osbuild

Issue:
`org.osbuild.tuned`: use `os.path.join()` and add a unit tests to verify the behavior with different profile paths
<snip> ... we could do a followup that moves to either pathlib (my preferred choice) or os.path.join() but then it should be consistent for the entire file and ideally we would add tests for that too (i.e. write some simple tests first, then convert the file and the tests should still pass)

_Originally posted by @mvo5 in https://github.com/osbuild/osbuild/pull/1935#pullrequestreview-2457746135_
            

Discussion:

Patch:
diff --git a/Schutzfile b/Schutzfile
index 8c3c6338..9451d875 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -19,7 +19,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f39/f39-x86_64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f39/f39-x86_64-updates-released-20250101"
           }
         ],
         "aarch64": [
@@ -31,7 +31,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f39/f39-aarch64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f39/f39-aarch64-updates-released-20250101"
           }
         ]
       }
@@ -50,7 +50,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-updates-released-20250101"
           }
         ],
         "aarch64": [
@@ -62,7 +62,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-updates-released-20250101"
           }
         ]
       }
@@ -81,7 +81,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-x86_64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-x86_64-updates-released-20250101"
           }
         ],
         "aarch64": [
@@ -93,7 +93,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-aarch64-updates-released-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-aarch64-updates-released-20250101"
           }
         ]
       }
@@ -107,34 +107,34 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20250101"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20250101"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20250101"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20250101"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20250101"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20250101"
           }
         ]
       }
@@ -148,34 +148,34 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-baseos-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-baseos-20250101"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-appstream-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-appstream-20250101"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-crb-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-crb-20250101"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-baseos-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-baseos-20250101"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-appstream-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-appstream-20250101"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-crb-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-crb-20250101"
           }
         ]
       }
@@ -189,34 +189,34 @@
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.5-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.5-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.5-20250101"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.5-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.5-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.5-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.5-20250101"
           }
         ]
       }
@@ -230,34 +230,34 @@
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.6-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.6-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.6-20250101"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.6-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.6-20250101"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.6-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.6-20250101"
           }
         ]
       }
@@ -271,34 +271,34 @@
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-baseos-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-baseos-n10.0-20250101"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-appstream-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-appstream-n10.0-20250101"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-crb-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-crb-n10.0-20250101"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-baseos-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-baseos-n10.0-20250101"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-appstream-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-appstream-n10.0-20250101"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-crb-n10.0-20241210"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-crb-n10.0-20250101"
           }
         ]
       }
diff --git a/osbuild/util/path.py b/osbuild/util/path.py
index 9f5ef246..035b385f 100644
--- a/osbuild/util/path.py
+++ b/osbuild/util/path.py
@@ -2,7 +2,7 @@
 import errno
 import os
 import os.path
-from typing import Optional
+from typing import Optional, Union
 
 from .ctx import suppress_oserror
 
@@ -41,3 +41,18 @@ def in_tree(path: str, tree: str, must_exist: bool = False) -> bool:
     if path.startswith(tree):
         return not must_exist or os.path.exists(path)
     return False
+
+
+def join_abs(root: Union[str, os.PathLike], *paths: Union[str, os.PathLike]) -> str:
+    """
+    Join root and paths together, handling the case where paths are absolute paths.
+    In that case, paths are just appended to root as if they were relative paths.
+    The result is always an absolute path relative to the filesystem root '/'.
+    """
+    final_path = root
+    for path in paths:
+        if os.path.isabs(path):
+            final_path = os.path.join(final_path, os.path.relpath(path, os.sep))
+        else:
+            final_path = os.path.join(final_path, path)
+    return os.path.normpath(os.path.join(os.sep, final_path))
diff --git a/schutzbot/team_ssh_keys.txt b/schutzbot/team_ssh_keys.txt
index 30f393b0..442af190 100644
--- a/schutzbot/team_ssh_keys.txt
+++ b/schutzbot/team_ssh_keys.txt
@@ -8,5 +8,5 @@ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDY/ylCrPBzil4TnZR4tWULpz3QgfBMQyEnMOHDAJNp
 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDjNynFZPCEPVDyOB2yzrww5kxwK6MAb1D0GN5yP8y/iw+gtx+Hj3CqojHMTa/9r3q3R1TMgCITdvzAiKylbx/owV8bgXS1p8je2KirWx3o/Dy80AYsas2F+sodm5/FOz6LvcUZw2vZiVs1wp8dz7ak+pm6Xg7xa7511xO4T/HStzNUE/XSPYmC9LNJ+uVWTiCjTWlZxp1JcDVfO7k69F60u8D42e1Ty60IeNeJItX/o8FUjB/rMAAJRpjFpd/uyfPTWamjNoVzrB7chFxaemg2Nf8na6PHLAx8Gcxz2fdnnsg+M5vr6z0yVYz1cc8VOhYynQm9iISvTt6bDVEbWc2T thozza@redhat.com
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSn7aXhxrfFHWDyAYZSGHJIyfq1NIClqibdtL4H5cB8 jrusz@fedora
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHUHIBlCKLUnlc4rVEnNAX913iEzVCFkN49yntwXi5Ye dustymabe@redhat.com dustymabe-ed25519-ci-osbuild
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIAYU2wzSk9r1l3iOwsvaJXCsfQIUga3xzShZJAM1zHv achilleas@Ruby
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPqEtsCdSozq0DT8sOazpizsBP65Ni6SMqrQA85Wnfs1 achilleas@Jack
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIAYU2wzSk9r1l3iOwsvaJXCsfQIUga3xzShZJAM1zHv akoutsou-R@redhat.com
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM+4pso8s0M0hKFW6XoEvM6loZp0C7D9ZlmwXQbhxyV0 akoutsou-i@redhat.com
diff --git a/schutzbot/terraform b/schutzbot/terraform
index e2e956d4..fb2f0e3b 100644
--- a/schutzbot/terraform
+++ b/schutzbot/terraform
@@ -1,1 +1,1 @@
-b580d2cf18c8745e883490010d5b6a3c5d8e3e20
+2c51ba92065231c4986894cc2bc214dea1b2c157
\ No newline at end of file
diff --git a/stages/org.osbuild.dnf4.sbom.spdx b/stages/org.osbuild.dnf4.sbom.spdx
index f8671be0..9ecb1a9d 100755
--- a/stages/org.osbuild.dnf4.sbom.spdx
+++ b/stages/org.osbuild.dnf4.sbom.spdx
@@ -6,6 +6,7 @@ import tempfile
 import dnf
 
 import osbuild
+from osbuild.util import path
 from osbuild.util.sbom.dnf import dnf_pkgset_to_sbom_pkgset
 from osbuild.util.sbom.spdx import sbom_pkgset_to_spdx2_doc
 
@@ -14,11 +15,11 @@ def get_installed_packages(tree):
     with tempfile.TemporaryDirectory() as tempdir:
         conf = dnf.conf.Conf()
         conf.installroot = tree
-        conf.persistdir = f"{tempdir}{conf.persistdir}"
-        conf.cachedir = f"{tempdir}{conf.cachedir}"
-        conf.reposdir = [f"{tree}{d}" for d in conf.reposdir]
-        conf.pluginconfpath = [f"{tree}{d}" for d in conf.pluginconfpath]
-        conf.varsdir = [f"{tree}{d}" for d in conf.varsdir]
+        conf.persistdir = path.join_abs(tempdir, conf.persistdir)
+        conf.cachedir = path.join_abs(tempdir, conf.cachedir)
+        conf.reposdir = [path.join_abs(tree, d) for d in conf.reposdir]
+        conf.pluginconfpath = [path.join_abs(tree, d) for d in conf.pluginconfpath]
+        conf.varsdir = [path.join_abs(tree, d) for d in conf.varsdir]
         conf.prepend_installroot("config_file_path")
 
         base = dnf.Base(conf)
diff --git a/stages/org.osbuild.dnf4.sbom.spdx.meta.json b/stages/org.osbuild.dnf4.sbom.spdx.meta.json
index 65571de5..f47ae335 100644
--- a/stages/org.osbuild.dnf4.sbom.spdx.meta.json
+++ b/stages/org.osbuild.dnf4.sbom.spdx.meta.json
@@ -1,5 +1,5 @@
 {
-  "summary": "Generate SPDX SBOM document for the installed packages.",
+  "summary": "Generate SPDX SBOM document for the installed packages using DNF4.",
   "description": [
     "The stage generates a Software Bill of Materials (SBOM) document",
     "in SPDX v2 format for the installed RPM packages. DNF4 API is used",
diff --git a/stages/org.osbuild.dnf5.sbom.spdx b/stages/org.osbuild.dnf5.sbom.spdx
index 892a47f4..24c52845 100755
--- a/stages/org.osbuild.dnf5.sbom.spdx
+++ b/stages/org.osbuild.dnf5.sbom.spdx
@@ -7,6 +7,7 @@ import tempfile
 import libdnf5
 
 import osbuild
+from osbuild.util import path
 from osbuild.util.sbom.dnf5 import dnf_pkgset_to_sbom_pkgset
 from osbuild.util.sbom.spdx import sbom_pkgset_to_spdx2_doc
 
@@ -17,18 +18,18 @@ def get_installed_packages(tree):
 
         conf = base.get_config()
         conf.installroot = tree
-        conf.config_file_path = f"{tree}{conf.config_file_path}"
-        conf.pluginpath = f"{tree}{conf.pluginpath}"
-        conf.pluginconfpath = f"{tree}{conf.pluginconfpath}"
-        conf.persistdir = f"{tree}{conf.persistdir}"
-        conf.transaction_history_dir = f"{tree}{conf.transaction_history_dir}"
-        conf.system_cachedir = f"{tempdir}{conf.system_cachedir}"
-        conf.varsdir = [f"{tree}{d}" for d in conf.varsdir]
-        conf.reposdir = [f"{tree}{d}" for d in conf.reposdir]
-        conf.cachedir = f"{tempdir}{conf.cachedir}"
-
-        if os.path.exists(f"{tree}{conf.system_state_dir}"):
-            conf.system_state_dir = f"{tree}{conf.system_state_dir}"
+        conf.config_file_path = path.join_abs(tree, conf.config_file_path)
+        conf.pluginpath = path.join_abs(tree, conf.pluginpath)
+        conf.pluginconfpath = path.join_abs(tree, conf.pluginconfpath)
+        conf.persistdir = path.join_abs(tree, conf.persistdir)
+        conf.transaction_history_dir = path.join_abs(tree, conf.transaction_history_dir)
+        conf.system_cachedir = path.join_abs(tempdir, conf.system_cachedir)
+        conf.varsdir = [path.join_abs(tree, d) for d in conf.varsdir]
+        conf.reposdir = [path.join_abs(tree, d) for d in conf.reposdir]
+        conf.cachedir = path.join_abs(tempdir, conf.cachedir)
+
+        if os.path.exists(path.join_abs(tree, conf.system_state_dir)):
+            conf.system_state_dir = path.join_abs(tree, conf.system_state_dir)
         else:
             # NB: if the directory does not exist in the tree, DNF5 would create it.
             # We need to ensure that the stage does not taint the tree with directories
diff --git a/stages/org.osbuild.dnf5.sbom.spdx.meta.json b/stages/org.osbuild.dnf5.sbom.spdx.meta.json
index 0be8c2d0..d025f4e8 100644
--- a/stages/org.osbuild.dnf5.sbom.spdx.meta.json
+++ b/stages/org.osbuild.dnf5.sbom.spdx.meta.json
@@ -1,5 +1,5 @@
 {
-  "summary": "Generate SPDX SBOM document for the installed packages.",
+  "summary": "Generate SPDX SBOM document for the installed packages using DNF5.",
   "description": [
     "The stage generates a Software Bill of Materials (SBOM) document",
     "in SPDX v2 format for the installed RPM packages. DNF5 API is used",
diff --git a/stages/org.osbuild.erofs b/stages/org.osbuild.erofs
index e52f2c6a..e15ec5c7 100755
--- a/stages/org.osbuild.erofs
+++ b/stages/org.osbuild.erofs
@@ -21,9 +21,13 @@ def main(inputs, output_dir, options):
             method += f",{compression['level']}"
         cmd += ["-z", method]
 
-    options = options.get("options")
-    if options:
-        cmd += ["-E", ",".join(options)]
+    erofs_options = options.get("options")
+    if erofs_options:
+        cmd += ["-E", ",".join(erofs_options)]
+
+    cluster_size = options.get("cluster-size")
+    if cluster_size:
+        cmd += ["-C", f"{cluster_size}"]
 
     subprocess.run(cmd, check=True)
 
diff --git a/stages/org.osbuild.erofs.meta.json b/stages/org.osbuild.erofs.meta.json
index 51ee70bb..0f675189 100644
--- a/stages/org.osbuild.erofs.meta.json
+++ b/stages/org.osbuild.erofs.meta.json
@@ -28,7 +28,8 @@
               "enum": [
                 "lz4",
                 "lz4hc",
-                "lzma"
+                "lzma",
+                "zstd"
               ]
             },
             "level": {
@@ -37,6 +38,10 @@
             }
           }
         },
+        "cluster-size": {
+          "description": "Maximum size of the compress physical cluster in bytes",
+          "type": "number"
+        },
         "options": {
           "description": "Extended options for the filesystem, see mkfs.erofs(1)",
           "type": "array",
diff --git a/stages/org.osbuild.tuned b/stages/org.osbuild.tuned
index 9df5f0df..3a90f632 100755
--- a/stages/org.osbuild.tuned
+++ b/stages/org.osbuild.tuned
@@ -3,6 +3,7 @@ import os
 import sys
 
 import osbuild.api
+from osbuild.util import path
 
 
 class TunedProfilesDB:
@@ -32,15 +33,18 @@ class TunedProfilesDB:
         ]
         profile_config_file = "tuned.conf"
 
-        for path in profile_directories:
-            profiles_dir = f"{tree}{path}"
+        for directory in profile_directories:
+            profiles_dir = path.join_abs(tree, directory)
+            if not os.path.isdir(profiles_dir):
+                continue
+
             # Since version 2.23.0, TuneD by default uses `profiles/` subdirectory.
             # If the subdirectory exists, we look for profiles there.
-            if os.path.isdir(f"{profiles_dir}/profiles"):
-                profiles_dir = f"{profiles_dir}/profiles"
+            if os.path.isdir(path.join_abs(profiles_dir, "profiles")):
+                profiles_dir = path.join_abs(profiles_dir, "profiles")
 
             for dir_entry in os.scandir(profiles_dir):
-                if dir_entry.is_dir() and os.path.isfile(f"{profiles_dir}/{dir_entry.name}/{profile_config_file}"):
+                if dir_entry.is_dir() and os.path.isfile(path.join_abs(profiles_dir, dir_entry.name, profile_config_file)):
                     available_profiles.add(dir_entry.name)
 
         return available_profiles
@@ -68,11 +72,11 @@ def main(tree, options):
             raise ValueError(f"TuneD profile '{profile}' does not exist")
 
     # Set the active profile
-    with open(f"{tree}{active_profile_file}", "w", encoding="utf8") as f:
+    with open(path.join_abs(tree, active_profile_file), "w", encoding="utf8") as f:
         f.write(" ".join(profiles) + "\n")
 
     # Mode needs to be set to "manual" if set explicitly
-    with open(f"{tree}{profile_mode_file}", "w", encoding="utf8") as f:
+    with open(path.join_abs(tree, profile_mode_file), "w", encoding="utf8") as f:
         f.write("manual\n")
 
     return 0
diff --git a/stages/org.osbuild.tuned.meta.json b/stages/org.osbuild.tuned.meta.json
index ed5ae355..634a5409 100644
--- a/stages/org.osbuild.tuned.meta.json
+++ b/stages/org.osbuild.tuned.meta.json
@@ -19,7 +19,8 @@
         "description": "TuneD profile to activate. If multiple profiles are provided, TuneD will try to merge them.",
         "minItems": 1,
         "items": {
-          "type": "string"
+          "type": "string",
+          "minLength": 1
         }
       }
     }
diff --git a/tools/osbuild-image-info b/tools/osbuild-image-info
index c082017b..7cb9aaf4 100755
--- a/tools/osbuild-image-info
+++ b/tools/osbuild-image-info
@@ -366,7 +366,14 @@ def read_boot_entries(boot_dir):
     entries = []
     for conf in glob.glob(f"{boot_dir}/loader/entries/*.conf"):
         with open(conf) as f:
-            entries.append(dict(line.strip().split(" ", 1) for line in f))
+            entry = dict()
+            for line in f:
+                line = line.strip()
+                if not line or line.startswith("#"):
+                    continue
+                key, value = line.split(" ", 1)
+                entry[key] = value
+            entries.append(entry)
 
     return sorted(entries, key=lambda e: e["title"])
 


Test patch:
diff --git a/stages/test/test_erofs.py b/stages/test/test_erofs.py
index 3b41519f..d74bde2d 100644
--- a/stages/test/test_erofs.py
+++ b/stages/test/test_erofs.py
@@ -15,6 +15,7 @@ TEST_INPUT = [
     ({"compression": {"method": "lz4hc", "level": 9}}, ["-z", "lz4hc,9"]),
     ({"options": ["dedupe"]}, ["-E", "dedupe"]),
     ({"options": ["all-fragments", "force-inode-compact"]}, ["-E", "all-fragments,force-inode-compact"]),
+    ({"cluster-size": 131072, "options": ["dedupe"]}, ["-E", "dedupe", "-C", "131072"]),
 ]
 
 
diff --git a/stages/test/test_tuned.py b/stages/test/test_tuned.py
new file mode 100644
index 00000000..23898eaa
--- /dev/null
+++ b/stages/test/test_tuned.py
@@ -0,0 +1,153 @@
+#!/usr/bin/python3
+
+import re
+
+import pytest
+
+from osbuild.testutil import (
+    assert_jsonschema_error_contains,
+    make_fake_tree,
+)
+
+STAGE_NAME = "org.osbuild.tuned"
+
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    # bad
+    ({"profiles": {}}, r"{} is not of type 'array'"),
+    ({"profiles": ""}, r"'' is not of type 'array'"),
+    ({"profiles": []}, r"(\[\] is too short|\[\] should be non-empty)"),
+    ({"profiles": [0]}, r"0 is not of type 'string'"),
+    ({"profiles": [""]}, r"('' is too short|'' should be non-empty)"),
+    ({}, r"'profiles' is a required property"),
+    # good
+    ({"profiles": ["balanced"]}, ""),
+    ({"profiles": ["balanced", "sap-hana"]}, ""),
+])
+def test_schema_validation(stage_schema, test_data, expected_err):
+    test_input = {
+        "type": STAGE_NAME,
+        "options": {},
+    }
+    test_input["options"].update(test_data)
+    res = stage_schema.validate(test_input)
+
+    if expected_err == "":
+        assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
+    else:
+        assert res.valid is False
+        assert_jsonschema_error_contains(res, re.compile(expected_err), expected_num_errs=1)
+
+
+@pytest.mark.parametrize("fake_tree,available_profiles", (
+    ({}, []),
+    # simple case with just installed profiles
+    (
+        {
+            "/usr/lib/tuned/balanced/tuned.conf": "",
+            "/usr/lib/tuned/sap/tuned.conf": ""
+        },
+        ["balanced", "sap"]
+    ),
+    # simple case with installed and custom profiles
+    (
+        {
+            "/usr/lib/tuned/balanced/tuned.conf": "",
+            "/usr/lib/tuned/sap/tuned.conf": "",
+            "/etc/tuned/custom/tuned.conf": ""
+        },
+        ["balanced", "sap", "custom"]
+    ),
+    # Tuned 2.23.0+ with profiles under profiles/ directory
+    # in which case we ignore profiles not under profiles/
+    (
+        {
+            "/usr/lib/tuned/profiles/balanced/tuned.conf": "",
+            "/usr/lib/tuned/sap/tuned.conf": ""
+        },
+        ["balanced"]
+    ),
+    (
+        {
+            "/usr/lib/tuned/profiles/balanced/tuned.conf": "",
+            "/etc/tuned/sap/tuned.conf": ""
+        },
+        ["balanced", "sap"]
+    ),
+    (
+        {
+            "/usr/lib/tuned/profiles/balanced/tuned.conf": "",
+            "/etc/tuned/profiles/sap/tuned.conf": ""
+        },
+        ["balanced", "sap"]
+    ),
+    (
+        {
+            "/etc/tuned/profiles/balanced/tuned.conf": "",
+            "/etc/tuned/sap/tuned.conf": ""
+        },
+        ["balanced"]
+    ),
+    (
+        {
+            "/usr/lib/tuned/profiles/profile1/tuned.conf": "",
+            "/usr/lib/tuned/profiles/profile2/tuned.conf": "",
+            "/usr/lib/tuned/profile3/tuned.conf": "",
+            "/etc/tuned/profiles/profile11/tuned.conf": "",
+            "/etc/tuned/profiles/profile12/tuned.conf": "",
+            "/etc/tuned/profile13/tuned.conf": "",
+        },
+        ["profile1", "profile2", "profile11", "profile12"]
+    ),
+))
+def test_tunedprofilesdb__load_available_profiles(tmp_path, stage_module, fake_tree, available_profiles):
+    make_fake_tree(tmp_path, fake_tree)
+    # pylint: disable=protected-access
+    assert sorted(stage_module.TunedProfilesDB._load_available_profiles(tmp_path)) == sorted(available_profiles)
+
+
+def test_tuned_happy(tmp_path, stage_module):
+    make_fake_tree(tmp_path, {
+        "/usr/lib/tuned/balanced/tuned.conf": "",
+        "/usr/lib/tuned/sap/tuned.conf": "",
+        "/etc/tuned/custom/tuned.conf": "",
+    })
+
+    options = {
+        "profiles": ["balanced", "sap", "custom"]
+    }
+
+    assert stage_module.main(tmp_path.as_posix(), options) == 0
+
+    active_profile_file = tmp_path / "etc/tuned/active_profile"
+    assert active_profile_file.is_file()
+    assert active_profile_file.read_text() == "balanced sap custom\n"
+
+    profile_mode_file = tmp_path / "etc/tuned/profile_mode"
+    assert profile_mode_file.is_file()
+    assert profile_mode_file.read_text() == "manual\n"
+
+
+def test_tuned_unhappy(tmp_path, stage_module):
+    make_fake_tree(tmp_path, {
+        "/usr/lib/tuned/balanced/tuned.conf": "",
+        "/usr/lib/tuned/sap/tuned.conf": "",
+        "/etc/tuned/custom/tuned.conf": "",
+    })
+
+    options = {
+        "profiles": ["balanced", "non-existing"]
+    }
+
+    try:
+        stage_module.main(tmp_path.as_posix(), options)
+    except ValueError as e:
+        assert "non-existing" in str(e)
+    else:
+        assert False, "Exception not raised"
+
+    active_profile_file = tmp_path / "etc/tuned/active_profile"
+    assert not active_profile_file.exists()
+
+    profile_mode_file = tmp_path / "etc/tuned/profile_mode"
+    assert not profile_mode_file.exists()
diff --git a/test/mod/test_util_path.py b/test/mod/test_util_path.py
index 017671b2..e14fe6e7 100644
--- a/test/mod/test_util_path.py
+++ b/test/mod/test_util_path.py
@@ -68,3 +68,122 @@ def test_in_tree():
 
     for args, expected in cases.items():
         assert path.in_tree(*args) == expected, args
+
+
+@pytest.mark.parametrize("test_case", (
+    # Strings only
+    {
+        "args": ("",),
+        "expected": "/"
+    },
+    {
+        "args": ("", "file"),
+        "expected": "/file"
+    },
+    {
+        "args": ("", "/file"),
+        "expected": "/file"
+    },
+    {
+        "args": ("/tmp", "/file"),
+        "expected": "/tmp/file"
+    },
+    {
+        "args": ("/tmp", "/foo", "/bar"),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": ("/", "/foo"),
+        "expected": "/foo"
+    },
+    {
+        "args": ("/", "/foo", "/bar"),
+        "expected": "/foo/bar"
+    },
+    # Path only
+    {
+        "args": (Path(""),),
+        "expected": "/"
+    },
+    {
+        "args": (Path(""), Path("file")),
+        "expected": "/file"
+    },
+    {
+        "args": (Path(""), Path("/file")),
+        "expected": "/file"
+    },
+    {
+        "args": (Path("/tmp"), Path("/file")),
+        "expected": "/tmp/file"
+    },
+    {
+        "args": (Path("/tmp"), Path("/foo"), Path("/bar")),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": (Path("/"), Path("/foo")),
+        "expected": "/foo"
+    },
+    {
+        "args": (Path("/"), Path("/foo"), Path("/bar")),
+        "expected": "/foo/bar"
+    },
+    # Mixed strings and Path
+    {
+        "args": (Path("/tmp"), "/foo", "/bar"),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": ("/tmp", Path("/foo"), Path("/bar")),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": ("/tmp", "/foo", Path("/bar")),
+        "expected": "/tmp/foo/bar"
+    },
+    # Dotted relative paths - strings
+    {
+        "args": ("/tmp", "../foo", "/bar"),
+        "expected": "/foo/bar"
+    },
+    {
+        "args": ("/tmp", "./foo", "/bar"),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": ("/tmp", "..", "/bar"),
+        "expected": "/bar"
+    },
+    {
+        "args": ("..",),
+        "expected": "/"
+    },
+    {
+        "args": (".",),
+        "expected": "/"
+    },
+    # Dotted relative paths - mixed strings and Path
+    {
+        "args": ("/tmp", Path("../foo"), "/bar"),
+        "expected": "/foo/bar"
+    },
+    {
+        "args": ("/tmp", Path("./foo"), "/bar"),
+        "expected": "/tmp/foo/bar"
+    },
+    {
+        "args": ("/tmp", Path(".."), "/bar"),
+        "expected": "/bar"
+    },
+    {
+        "args": (Path(".."),),
+        "expected": "/"
+    },
+    {
+        "args": (Path("."),),
+        "expected": "/"
+    },
+))
+def test_join_abs(test_case):
+    assert path.join_abs(*test_case["args"]) == test_case["expected"]
diff --git a/tools/test/test_osbuild_image_info.py b/tools/test/test_osbuild_image_info.py
new file mode 100644
index 00000000..1386217a
--- /dev/null
+++ b/tools/test/test_osbuild_image_info.py
@@ -0,0 +1,137 @@
+import pytest
+
+from osbuild.testutil import make_fake_tree
+from osbuild.testutil.imports import import_module_from_path
+
+osbuild_image_info = import_module_from_path("osbuild_image_info", "tools/osbuild-image-info")
+
+
+@pytest.mark.parametrize("fake_tree,entries", (
+    # no entries
+    ({}, []),
+    # one entry
+    (
+        {
+            "/boot/loader/entries/0649288e52434223afde4c36460a375e-6.11.9-100.fc39.x86_64.conf": """title Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)
+version 6.11.9-100.fc39.x86_64
+linux /boot/vmlinuz-6.11.9-100.fc39.x86_64
+initrd /boot/initramfs-6.11.9-100.fc39.x86_64.img
+options root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8
+grub_users $grub_users
+grub_arg --unrestricted
+grub_class fedora""",
+        },
+        [
+            {
+                "title": "Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)",
+                "version": "6.11.9-100.fc39.x86_64",
+                "linux": "/boot/vmlinuz-6.11.9-100.fc39.x86_64",
+                "initrd": "/boot/initramfs-6.11.9-100.fc39.x86_64.img",
+                "options": "root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8",
+                "grub_users": "$grub_users",
+                "grub_arg": "--unrestricted",
+                "grub_class": "fedora",
+            },
+        ]
+    ),
+    # two entries
+    (
+        {
+            "/boot/loader/entries/0649288e52434223afde4c36460a375e-6.11.9-100.fc39.x86_64.conf": """title Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)
+version 6.11.9-100.fc39.x86_64
+linux /boot/vmlinuz-6.11.9-100.fc39.x86_64
+initrd /boot/initramfs-6.11.9-100.fc39.x86_64.img
+options root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8
+grub_users $grub_users
+grub_arg --unrestricted
+grub_class fedora""",
+            "/boot/loader/entries/0649288e52434223afde4c36460a375e-6.11.9-101.fc39.x86_64.conf": """title Fedora Linux (6.11.9-101.fc39.x86_64) 39 (Thirty Nine)
+version 6.11.9-101.fc39.x86_64
+linux /boot/vmlinuz-6.11.9-101.fc39.x86_64
+initrd /boot/initramfs-6.11.9-101.fc39.x86_64.img
+options root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8
+grub_users $grub_users
+grub_arg --unrestricted
+grub_class fedora""",
+        },
+        [
+            {
+                "title": "Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)",
+                "version": "6.11.9-100.fc39.x86_64",
+                "linux": "/boot/vmlinuz-6.11.9-100.fc39.x86_64",
+                "initrd": "/boot/initramfs-6.11.9-100.fc39.x86_64.img",
+                "options": "root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8",
+                "grub_users": "$grub_users",
+                "grub_arg": "--unrestricted",
+                "grub_class": "fedora",
+            },
+            {
+                "title": "Fedora Linux (6.11.9-101.fc39.x86_64) 39 (Thirty Nine)",
+                "version": "6.11.9-101.fc39.x86_64",
+                "linux": "/boot/vmlinuz-6.11.9-101.fc39.x86_64",
+                "initrd": "/boot/initramfs-6.11.9-101.fc39.x86_64.img",
+                "options": "root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8",
+                "grub_users": "$grub_users",
+                "grub_arg": "--unrestricted",
+                "grub_class": "fedora",
+            },
+        ]
+    ),
+    # one entry with extra newlines
+    (
+        {
+            "/boot/loader/entries/0649288e52434223afde4c36460a375e-6.11.9-100.fc39.x86_64.conf": """title Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)
+version 6.11.9-100.fc39.x86_64
+linux /boot/vmlinuz-6.11.9-100.fc39.x86_64
+initrd /boot/initramfs-6.11.9-100.fc39.x86_64.img
+options root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8
+grub_users $grub_users
+grub_arg --unrestricted
+grub_class fedora
+
+""",
+        },
+        [
+            {
+                "title": "Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)",
+                "version": "6.11.9-100.fc39.x86_64",
+                "linux": "/boot/vmlinuz-6.11.9-100.fc39.x86_64",
+                "initrd": "/boot/initramfs-6.11.9-100.fc39.x86_64.img",
+                "options": "root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8",
+                "grub_users": "$grub_users",
+                "grub_arg": "--unrestricted",
+                "grub_class": "fedora",
+            },
+        ]
+    ),
+    # one entry with comments
+    (
+        {
+            "/boot/loader/entries/0649288e52434223afde4c36460a375e-6.11.9-100.fc39.x86_64.conf": """title Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)
+# this is a very useful comment
+version 6.11.9-100.fc39.x86_64
+linux /boot/vmlinuz-6.11.9-100.fc39.x86_64
+initrd /boot/initramfs-6.11.9-100.fc39.x86_64.img
+options root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8
+# this is another very useful comment
+grub_users $grub_users
+grub_arg --unrestricted
+grub_class fedora""",
+        },
+        [
+            {
+                "title": "Fedora Linux (6.11.9-100.fc39.x86_64) 39 (Thirty Nine)",
+                "version": "6.11.9-100.fc39.x86_64",
+                "linux": "/boot/vmlinuz-6.11.9-100.fc39.x86_64",
+                "initrd": "/boot/initramfs-6.11.9-100.fc39.x86_64.img",
+                "options": "root=UUID=a7e970a5-14fb-4a8a-ab09-603d1ac3fee9 ro crashkernel=auto net.ifnames=0 rhgb console=tty0 console=ttyS0,115200n8",
+                "grub_users": "$grub_users",
+                "grub_arg": "--unrestricted",
+                "grub_class": "fedora",
+            },
+        ]
+    ),
+))
+def test_read_boot_entries(tmp_path, fake_tree, entries):
+    make_fake_tree(tmp_path, fake_tree)
+    assert osbuild_image_info.read_boot_entries(tmp_path / "boot") == entries