Repository: osbuild/osbuild
Pull request #2002 (2025-02-05 12:39:18)

stages/coreos.live-artifacts: add erofs support
This adds possibility to specify filesystem of `rootfs.img`. 

Issue: https://github.com/coreos/fedora-coreos-tracker/issues/1852

dustymabe: ```
      "properties": {
        "live_rootfs_fstype": {
          "descripton": "Filesystem of live-rootfs.img",
          "type": "string",
          "enum": [
            "erofs",
            "squashfs"
          ],
          "default": "squashfs"
        },
        "live_rootfs_fsoptions": {
          "descripton": "Parameters for generating live-rootfs.img",
          "type": "string",
          "default": "-comp zstd"
        },
```

We discussed this in a meeting last week with @mvo5. What we (CoreOS team) would really like to do here is actually define these settings inside of the payload that is the CoreOS (Fedora CoreOS or RHEL CoreOS) filesystem/target image.

The question we have here is: can we inspect and do things based on the target image?

If yes then what's preferred? Should we base our behavior on:

- target OS versions?
    - Seems hacky to have business-y logic as part of an osbuild stage
- target OS capabilities?
    - i.e. if erofs is supported then do erofs
- some configuration files inside the target OS?
    - (e.g. bootc print-config/or file)

Our preference at this point is to base it on some configuration intentionally applied inside the target image.

nikita-dubrovskii: > We discussed this in a meeting last week with @mvo5. What we (CoreOS team) would really like to do here is actually define these settings inside of the payload that is the CoreOS (Fedora CoreOS or RHEL CoreOS) filesystem/target image.

Right, i've opened this PR to start the discussion. Reading configuration file(s) is in progress right now.
nikita-dubrovskii: @dustymabe , @jlebon as discussed, 2nd patch here adds ability to get options from file
dustymabe: I'll review and test this today. Thanks @nikita-dubrovskii 

Patch:
diff --git a/sources/org.osbuild.ostree b/sources/org.osbuild.ostree
index 8c9aaaba..ed307433 100755
--- a/sources/org.osbuild.ostree
+++ b/sources/org.osbuild.ostree
@@ -16,11 +16,9 @@ supported are:
   entitlement certificate. Secret fields `consumer_cert` and
   `consumer_key` must be set.
 
-When one or more subpaths are specified, only those are pulled, otherwise the
-whole tree is pulled. To skip TLS verification, set
-OSBUILD_SOURCES_OSTREE_INSECURE environment variable to "true". To set a
-HTTP(S) proxy, set OSBUILD_SOURCES_OSTREE_PROXY environment variable to the
-proxy URL.
+To skip TLS verification, set OSBUILD_SOURCES_OSTREE_INSECURE environment
+variable to "true". To set a HTTP(S) proxy, set OSBUILD_SOURCES_OSTREE_PROXY
+environment variable to the proxy URL.
 """
 
 
@@ -59,13 +57,6 @@ SCHEMA = """
                 "type": "string",
                 "description": "content URL of the repository."
               },
-              "subpaths": {
-                "type": "array",
-                "items": {
-                  "type": "string",
-                  "description": "only include path(s) when fetching (fetch all when empty)"
-                }
-              },
               "gpgkeys": {
                 "type": "array",
                 "items": {
@@ -123,9 +114,6 @@ class OSTreeSource(sources.SourceService):
     def fetch_one(self, checksum, desc):
         commit = checksum
         remote = desc["remote"]
-        subpaths = remote.get("subpaths", [])
-        subpaths = [f'--subpath={p}' for p in subpaths]
-
         # This is a temporary remote so we'll just use a random name
         name = str(uuid.uuid4())
 
@@ -133,7 +121,7 @@ class OSTreeSource(sources.SourceService):
 
         # Transfer the commit: remote → cache
         print(f"pulling {commit}", file=sys.stderr)
-        ostree.cli("pull", name, commit, *subpaths, repo=self.repo)
+        ostree.cli("pull", name, commit, repo=self.repo)
 
         # Remove the temporary remote again
         ostree.cli("remote", "delete", name, repo=self.repo)
diff --git a/stages/org.osbuild.coreos.live-artifacts.mono b/stages/org.osbuild.coreos.live-artifacts.mono
index ca5891cc..f3f7178f 100755
--- a/stages/org.osbuild.coreos.live-artifacts.mono
+++ b/stages/org.osbuild.coreos.live-artifacts.mono
@@ -83,13 +83,14 @@ def mkinitrd_pipe(tmproot, destf, compress=True):
     files = subprocess.check_output(['find', '.', '-mindepth', '1', '-print0'],
                                     cwd=tmproot)
     file_list = files.split(b'\0')
-    # If there's a root.squashfs, it _must_ be the first file in the cpio
+    # If there's a root.[squash|ero]fs, it _must_ be the first file in the cpio
     # archive, since the dracut 20live module assumes its contents are at
     # a fixed offset in the archive.
-    squashfs = b'./root.squashfs'
-    if squashfs in file_list:
-        file_list.remove(squashfs)
-        file_list.insert(0, squashfs)
+    for filename in [b'./root.squashfs', b'./root.erofs']:
+        if filename in file_list:
+            file_list.remove(filename)
+            file_list.insert(0, filename)
+            break
     cpioproc = subprocess.Popen(['cpio', '-o', '-H', 'newc', '-R', 'root:root',
                                  '--quiet', '--reproducible', '--force-local', '--null',
                                  '-D', tmproot], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
@@ -544,14 +545,13 @@ boot
     return "images/efiboot.img"
 
 
-def mksquashfs_metal(paths, workdir, img_metal, loop_client):
+def mkrootfs_metal(paths, workdir, img_metal, fstype, fsoptions, loop_client):
     """
-    Mounts a copy of the metal image and modifies it accordingly to create a (squashfs) rootfs from its contents for the
-    live ISO.
+    Mounts a copy of the metal image and modifies it accordingly to create a (fstype) rootfs from its contents for the
+    live ISO. fstype must be squashfs or erofs.
 
     Returns the bls entry kernel arguments for the ISO bootloader.
     """
-    squashfs_compression = 'zstd'
     basearch = os.uname().machine
     tmp_squashfs_dir = os.path.join(workdir, 'tmp-squashfs-dir')
     os.mkdir(tmp_squashfs_dir)
@@ -601,17 +601,23 @@ def mksquashfs_metal(paths, workdir, img_metal, loop_client):
             print(f"Kernel binary linked: {tmp_squashfs_dir}/boot/{kernel_binary_basename}")
             print(f"Kernel HMAC linked: {tmp_squashfs_dir}/boot/{kernel_hmac_basename}")
             # Generate root squashfs
-            print(f'Compressing squashfs with {squashfs_compression}')
+            print(f'Creating {fstype} with {fsoptions}')
 
             # Note the filename must be exactly "root.squashfs" because the 20live
             # dracut module makes assumptions about the length of the name in sysroot.mount
             # this matches the set of flags we implicitly passed when doing this
             # through libguestfs' mksquashfs command
-            subprocess.check_call(['mksquashfs', tmp_squashfs_dir,
-                                   paths["initrd-rootfs/root.squashfs"],
-                                   '-root-becomes', tmp_squashfs_dir,
-                                   '-wildcards', '-no-recovery',
-                                   '-comp', squashfs_compression])
+            if fstype == "erofs":
+                subprocess.check_call(['mkfs.erofs',
+                                       *fsoptions.split(' '),
+                                       paths["initrd-rootfs/root.erofs"],
+                                       tmp_squashfs_dir])
+            else:
+                subprocess.check_call(['mksquashfs', tmp_squashfs_dir,
+                                       paths["initrd-rootfs/root.squashfs"],
+                                       '-root-becomes', tmp_squashfs_dir,
+                                       '-wildcards', '-no-recovery',
+                                       *fsoptions.split(' ')])
 
             # while it's mounted here, also get the kargs
             blsentry = ensure_glob(os.path.join(tmp_squashfs_dir, 'boot/loader/entries/*.conf'), n=1)[0]
@@ -776,7 +782,7 @@ def mk_osmet_files(deployed_tree, img_metal, img_metal4k, loop_client, paths, os
                 chroot.run(cmd, check=True)
 
 
-def mk_paths(workdir):
+def mk_paths(workdir, fstype):
     """
     Returns a dictionary with all the paths under workdir needed throughout multiple functions in the stage.
     """
@@ -808,7 +814,7 @@ def mk_paths(workdir):
         "initrd/etc/coreos-live-want-rootfs": "",
         "initrd/etc/coreos-live-initramfs": "",
         "initrd-rootfs": "",
-        "initrd-rootfs/root.squashfs": "",
+        f"initrd-rootfs/root.{fstype}": ""
     }
     for key in paths:
         paths[key] = os.path.join(workdir, key)
@@ -864,7 +870,13 @@ def main(workdir, tree, inputs, options, loop_client):
     os_release = osrelease.parse_files(os.path.join(deployed_tree, 'etc', 'os-release'))
     version = os_release['OSTREE_VERSION']
 
-    paths = mk_paths(workdir)
+    # Check whether live-rootfs options set
+    with open(os.path.join(deployed_tree, "usr/share/coreos-assembler", "image.json"), "r", encoding="utf8") as f:
+        parsed = json.load(f)
+        fstype = parsed.get("live-rootfs-fstype", "squashfs")
+        fsoptions = parsed.get("live-rootfs-fsoptions", "-comp zstd" if fstype == "squashfs" else "--quiet")
+
+    paths = mk_paths(workdir, fstype)
     mk_workdirs(paths)
 
     # Find the directory under `/usr/lib/modules/<kver>` where the
@@ -905,19 +917,26 @@ def main(workdir, tree, inputs, options, loop_client):
 
     mk_osmet_files(deployed_tree, img_metal, img_metal4k, loop_client, paths, os_release)
 
-    blsentry_kargs = mksquashfs_metal(paths, workdir, img_metal, loop_client)
+    blsentry_kargs = mkrootfs_metal(paths, workdir, img_metal, fstype, fsoptions, loop_client)
 
     # Generate rootfs image
     # The rootfs must be uncompressed because the ISO mounts root.squashfs
     # directly from the middle of the file
     extend_initramfs(initramfs=paths["iso/images/pxeboot/rootfs.img"],
                      tree=paths["initrd-rootfs"], compress=False)
-    # Check that the root.squashfs magic number is in the offset hardcoded
+    # Check that the root.[squash|ero]fs magic number is in the offset hardcoded
     # in sysroot.mount in 20live/live-generator
+    offset = 124
+    magic = b'hsqs'  # squashfs magic
+    if fstype == "erofs":
+        # The erofs's superblock starts at an absolute offset 1024 bytes
+        # https://erofs.docs.kernel.org/en/latest/core_ondisk.html#superblock
+        offset += 1024
+        magic = bytes.fromhex("e2e1f5e0")
     with open(paths["iso/images/pxeboot/rootfs.img"], 'rb') as fh:
-        fh.seek(124)
-        if fh.read(4) != b'hsqs':
-            raise ValueError("root.squashfs not at expected offset in rootfs image")
+        fh.seek(offset)
+        if fh.read(4) != magic:
+            raise ValueError(f"root.{fstype} not at expected offset in rootfs image")
     # Save stream hash of rootfs for verifying out-of-band fetches
     os.makedirs(os.path.dirname(paths["initrd/etc/coreos-live-want-rootfs"]), exist_ok=True)
     make_stream_hash(paths["iso/images/pxeboot/rootfs.img"],
diff --git a/tools/osbuild-image-info b/tools/osbuild-image-info
index 983b86d5..11a9c3bb 100755
--- a/tools/osbuild-image-info
+++ b/tools/osbuild-image-info
@@ -1118,9 +1118,13 @@ def read_selinux_ctx_mismatch(tree, is_ostree):
         setfiles_pattern = r"Would\s+relabel\s+(?P<filename>.+)\s+from\s+(?P<actual>.+)\s+to\s+(?P<expected>.+)"
         setfiles_re = re.compile(setfiles_pattern)
 
+        # skip messages about old compiled fcontext format
+        binary_fcontext_skip = rf"{tree}/etc/selinux/targeted/contexts/files/file_contexts.*\.bin:\s+Old compiled fcontext format, skipping"
+        binary_fcontext_skip_re = re.compile(binary_fcontext_skip)
+
         for line in output.splitlines():
             line = line.strip()
-            if not line:
+            if not line or binary_fcontext_skip_re.match(line):
                 continue
             match = setfiles_re.match(line)
             # do not silently ignore changes of 'setfiles' output


Test patch:
diff --git a/osbuild/testutil/net.py b/osbuild/testutil/net.py
index 183fdb58..bb8e2885 100644
--- a/osbuild/testutil/net.py
+++ b/osbuild/testutil/net.py
@@ -4,10 +4,8 @@ network related utilities
 """
 import contextlib
 import http.server
-import os
 import socket
 import ssl
-import sys
 import threading
 
 try:
@@ -27,12 +25,6 @@ except ImportError:
 from .atomic import AtomicCounter
 
 
-def print_dir(directory):
-    for root, _, files in os.walk(directory):
-        for fn in files:
-            print(os.path.join(root, fn))
-
-
 def _get_free_port():
     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
     s.bind(("localhost", 0))
@@ -55,8 +47,6 @@ class SilentHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
 class DirHTTPServer(ThreadingHTTPServer):
     def __init__(self, *args, directory=None, simulate_failures=0, **kwargs):
         super().__init__(*args, **kwargs)
-        print("Serving:", file=sys.stderr)
-        print_dir(directory)
         self.directory = directory
         self.simulate_failures = AtomicCounter(simulate_failures)
         self.reqs = AtomicCounter()
diff --git a/sources/test/test_ostree_source.py b/sources/test/test_ostree_source.py
index 48df06f7..8d1ab750 100644
--- a/sources/test/test_ostree_source.py
+++ b/sources/test/test_ostree_source.py
@@ -1,6 +1,5 @@
 #!/usr/bin/python3
 
-import os
 import pathlib
 import tempfile
 
@@ -28,12 +27,11 @@ def test_ostree_source_exists(tmp_path, sources_service):
     assert sources_service.exists("sha256:" + commit, None)
 
 
-def make_test_sources(proto, port, fake_commit, subpaths, **secrets):
+def make_test_sources(proto, port, fake_commit, **secrets):
     sources = {
         fake_commit: {
             "remote": {
                 "url": f"{proto}://localhost:{port}",
-                "subpaths": subpaths,
             }
         }
     }
@@ -44,9 +42,7 @@ def make_test_sources(proto, port, fake_commit, subpaths, **secrets):
 
 def make_repo(root):
     with tempfile.TemporaryDirectory() as empty_tmpdir:
-        ostree.cli("init", "--mode=archive", f"--repo={root}")
-        os.mknod(os.path.join(empty_tmpdir, "a.txt"))
-        os.mknod(os.path.join(empty_tmpdir, "b.txt"))
+        ostree.cli("init", f"--repo={root}")
         return ostree.cli("commit", f"--repo={root}", "--orphan", empty_tmpdir).stdout.rstrip()
 
 
@@ -57,27 +53,12 @@ def test_ostree_pull_plain(tmp_path, sources_service):
     fake_commit = make_repo(fake_httpd_root)
 
     with http_serve_directory(fake_httpd_root) as httpd:
-        test_sources = make_test_sources("http", httpd.server_port, fake_commit, [])
+        test_sources = make_test_sources("http", httpd.server_port, fake_commit)
         sources_service.setup({"cache": tmp_path, "options": {}})
         sources_service.fetch_all(test_sources)
         assert sources_service.exists("sha256:" + fake_commit, None)
 
 
-@pytest.mark.skipif(not has_executable("ostree"), reason="need ostree")
-def test_ostree_pull_subtree(tmp_path, sources_service, capsys):
-    fake_httpd_root = tmp_path / "fake-httpd-root"
-    fake_httpd_root.mkdir(exist_ok=True)
-    fake_commit = make_repo(fake_httpd_root)
-
-    with http_serve_directory(fake_httpd_root) as httpd:
-        test_sources = make_test_sources("http", httpd.server_port, fake_commit, ["/b.txt"])
-        sources_service.setup({"cache": tmp_path, "options": {}})
-        sources_service.fetch_all(test_sources)
-        assert sources_service.exists("sha256:" + fake_commit, None)
-
-    assert "--subpath=/b.txt" in capsys.readouterr().err
-
-
 @pytest.mark.skipif(not has_executable("ostree"), reason="need ostree")
 def test_ostree_pull_plain_mtls(tmp_path, sources_service, monkeypatch):
     fake_httpd_root = tmp_path / "fake-httpd-root"
@@ -92,7 +73,7 @@ def test_ostree_pull_plain_mtls(tmp_path, sources_service, monkeypatch):
 
     with https_serve_directory(fake_httpd_root, cert1, key1) as httpd:
         monkeypatch.setenv("OSBUILD_SOURCES_OSTREE_INSECURE", "1")
-        test_sources = make_test_sources("https", httpd.server_port, fake_commit, [], name="org.osbuild.mtls")
+        test_sources = make_test_sources("https", httpd.server_port, fake_commit, name="org.osbuild.mtls")
         sources_service.setup({"cache": tmp_path, "options": {}})
         sources_service.fetch_all(test_sources)
         assert sources_service.exists("sha256:" + fake_commit, None)
diff --git a/stages/test/test_coreos_live_artifacts_mono.py b/stages/test/test_coreos_live_artifacts_mono.py
index f97c3fc5..06f6c407 100644
--- a/stages/test/test_coreos_live_artifacts_mono.py
+++ b/stages/test/test_coreos_live_artifacts_mono.py
@@ -59,6 +59,7 @@ def test_align_initrd(tmp_path, stage_module, test_case):
     {"treefiles": ["file_b", "file_c", "root.squashfs", "file_1"], "compress": False},
     {"treefiles": ["file_b", "file_c", "file_1"], "compress": True},
     {"treefiles": ["file_b", "file_c", "root.squashfs", "file_1"], "compress": True},
+    {"treefiles": ["file_b", "file_c", "root.erofs", "file_1"], "compress": True},
 ])
 def test_extend_initramfs(tmp_path, stage_module, test_case):
     img_path = tmp_path / "img"
@@ -88,6 +89,9 @@ def test_extend_initramfs(tmp_path, stage_module, test_case):
     if "root.squashfs" in treefiles:
         # root.squashfs must be first
         assert files_in_archive[0] == "root.squashfs"
+    if "root.erofs" in treefiles:
+        # root.erofs must be first
+        assert files_in_archive[0] == "root.erofs"
 
 
 def test_make_stream_hash(tmp_path, stage_module):
diff --git a/tools/test/test_osbuild_image_info.py b/tools/test/test_osbuild_image_info.py
index 61b0de56..07b7b3c8 100644
--- a/tools/test/test_osbuild_image_info.py
+++ b/tools/test/test_osbuild_image_info.py
@@ -224,3 +224,116 @@ def test_analyse_iso_fail_no_tarball(tmp_path):
             subprocess.CalledProcessError,
             match=r"^Command '\['tar', '--selinux', '--xattrs', '--acls', '-x', '--auto-compress', '-f', '/tmp/\w+/liveimg.tar.gz"):
         osbuild_image_info.analyse_iso(image_path)
+
+
+@pytest.mark.parametrize("subprocess_output,expected_report", [
+    pytest.param(
+        """Would relabel {tmp_path}/etc/shells from unconfined_u:object_r:etc_t:s0 to system_u:object_r:etc_t:s0
+Would relabel {tmp_path}/etc/ld.so.cache from unconfined_u:object_r:ld_so_cache_t:s0 to system_u:object_r:ld_so_cache_t:s0
+Would relabel {tmp_path}/etc/alternatives/roff.7.gz from unconfined_u:object_r:etc_t:s0 to system_u:object_r:etc_t:s0
+Would relabel {tmp_path}/var/lib/selinux/targeted/active from unconfined_u:object_r:semanage_store_t:s0 to system_u:object_r:semanage_store_t:s0
+Would relabel {tmp_path}/var/lib/alternatives/roff.7.gz from unconfined_u:object_r:rpm_var_lib_t:s0 to system_u:object_r:rpm_var_lib_t:s0
+""",
+        [
+            {
+                "filename": "/etc/alternatives/roff.7.gz",
+                "actual": "unconfined_u:object_r:etc_t:s0",
+                "expected": "system_u:object_r:etc_t:s0",
+            },
+            {
+                "filename": "/etc/ld.so.cache",
+                "actual": "unconfined_u:object_r:ld_so_cache_t:s0",
+                "expected": "system_u:object_r:ld_so_cache_t:s0",
+            },
+            {
+                "filename": "/etc/shells",
+                "actual": "unconfined_u:object_r:etc_t:s0",
+                "expected": "system_u:object_r:etc_t:s0",
+            },
+            {
+                "filename": "/var/lib/alternatives/roff.7.gz",
+                "actual": "unconfined_u:object_r:rpm_var_lib_t:s0",
+                "expected": "system_u:object_r:rpm_var_lib_t:s0",
+            },
+            {
+                "filename": "/var/lib/selinux/targeted/active",
+                "actual": "unconfined_u:object_r:semanage_store_t:s0",
+                "expected": "system_u:object_r:semanage_store_t:s0",
+            },
+        ],
+        id="happy case",
+    ),
+    pytest.param(
+        "",
+        [],
+        id="empty",
+    ),
+    pytest.param(
+        """{tmp_path}/etc/selinux/targeted/contexts/files/file_contexts.bin:  Old compiled fcontext format, skipping
+{tmp_path}/etc/selinux/targeted/contexts/files/file_contexts.homedirs.bin:  Old compiled fcontext format, skipping
+""",
+        [],
+        id="only lines to skip",
+    ),
+    pytest.param(
+        """{tmp_path}/etc/selinux/targeted/contexts/files/file_contexts.bin:  Old compiled fcontext format, skipping
+{tmp_path}/etc/selinux/targeted/contexts/files/file_contexts.homedirs.bin:  Old compiled fcontext format, skipping
+
+Would relabel {tmp_path}/etc/shells from unconfined_u:object_r:etc_t:s0 to system_u:object_r:etc_t:s0
+Would relabel {tmp_path}/etc/ld.so.cache from unconfined_u:object_r:ld_so_cache_t:s0 to system_u:object_r:ld_so_cache_t:s0
+Would relabel {tmp_path}/etc/alternatives/roff.7.gz from unconfined_u:object_r:etc_t:s0 to system_u:object_r:etc_t:s0
+Would relabel {tmp_path}/var/lib/selinux/targeted/active from unconfined_u:object_r:semanage_store_t:s0 to system_u:object_r:semanage_store_t:s0
+Would relabel {tmp_path}/var/lib/alternatives/roff.7.gz from unconfined_u:object_r:rpm_var_lib_t:s0 to system_u:object_r:rpm_var_lib_t:s0
+""",
+        [
+            {
+                "filename": "/etc/alternatives/roff.7.gz",
+                "actual": "unconfined_u:object_r:etc_t:s0",
+                "expected": "system_u:object_r:etc_t:s0",
+            },
+            {
+                "filename": "/etc/ld.so.cache",
+                "actual": "unconfined_u:object_r:ld_so_cache_t:s0",
+                "expected": "system_u:object_r:ld_so_cache_t:s0",
+            },
+            {
+                "filename": "/etc/shells",
+                "actual": "unconfined_u:object_r:etc_t:s0",
+                "expected": "system_u:object_r:etc_t:s0",
+            },
+            {
+                "filename": "/var/lib/alternatives/roff.7.gz",
+                "actual": "unconfined_u:object_r:rpm_var_lib_t:s0",
+                "expected": "system_u:object_r:rpm_var_lib_t:s0",
+            },
+            {
+                "filename": "/var/lib/selinux/targeted/active",
+                "actual": "unconfined_u:object_r:semanage_store_t:s0",
+                "expected": "system_u:object_r:semanage_store_t:s0",
+            },
+        ],
+        id="valid lines mixed with lines to skip",
+    )
+])
+def test_read_selinux_ctx_mismatch(tmp_path, subprocess_output, expected_report):
+    """
+    Test the read_selinux_ctx_mismatch function
+    """
+    policy_dir = tmp_path / "etc/selinux/targeted/policy"
+    policy_dir.mkdir(parents=True)
+    policy_file = policy_dir / "policy.33"
+    policy_file.touch()
+
+    with patch("subprocess.check_output") as subprocess_check_output:
+        subprocess_check_output.return_value = subprocess_output.format(tmp_path=tmp_path)
+        report = osbuild_image_info.read_selinux_ctx_mismatch(tmp_path.as_posix(), False)
+
+        assert subprocess_check_output.call_count == 1
+        assert subprocess_check_output.call_args[0][0] == [
+            "setfiles", "-r", tmp_path.as_posix(),
+            "-nvF",
+            "-c", os.fspath(tmp_path / "etc/selinux/targeted/policy/policy.33"),
+            os.fspath(tmp_path / "etc/selinux/targeted/contexts/files/file_contexts"),
+            tmp_path.as_posix(),
+        ]
+        assert report == expected_report