Repository: osbuild/osbuild
Pull request #620 (2021-03-25 15:13:26)

Fix issue with changing content in /etc/passwd and /etc/groups in OSTree deployments
See commit messages for further explanation.

Reproducer for testing the passwd and group merge (doesn't really verify the issue with kernel-rt, which should be tested in `osbuild-composer` instead):
```bash
#!/bin/bash

set -ex

TMPDIR=$(mktemp --directory)

pushd "${TMPDIR}"
ostree --repo=ostree-passwd init
mkdir -p tree/usr/lib
mkdir -p tree/usr/etc
cat > tree/usr/lib/passwd << STOPHERE
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
STOPHERE
cat > tree/usr/etc/passwd << STOPHERE
bin:x:8:8:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
STOPHERE
cat > tree/usr/lib/group << STOPHERE
root:x:0:
bin:x:1:
STOPHERE
cat > tree/usr/etc/group << STOPHERE
bin:x:8:
daemon:x:2:
STOPHERE

COMMIT=$(ostree --repo=ostree-passwd commit --branch=foo tree/)
popd

cat > "${TMPDIR}/manifest.json" << STOPHERE
{
    "version": "2",
    "sources": {
        "org.osbuild.ostree": {
            "items": {
              "${COMMIT}": {
                "remote": {
                  "url": "file://${TMPDIR}/ostree-passwd"
                }
              }
            }
          }
    },
    "pipelines": [
        {
            "name": "ostree-passwd",
            "stages": [
                {
                    "type": "org.osbuild.ostree.passwd",
                    "inputs": {
                        "commits": {
                            "type": "org.osbuild.ostree.checkout",
                            "origin": "org.osbuild.source",
                            "references": [
                                "${COMMIT}"
                            ]
                        }
                    }
                }
            ]
        }
    ]
}
STOPHERE

sudo rm -rf .osbuild/*
REF=$(sudo osbuild --output-directory /tmp --libdir . --json "${TMPDIR}"/manifest.json | jq --raw-output '.log."ostree-passwd"[0]."id"')
sudo osbuild --checkpoint="${REF}" --libdir . "${TMPDIR}"/manifest.json
cat ".osbuild/refs/${REF}/etc/passwd"
cat ".osbuild/refs/${REF}/etc/group"

```

msehnout: > Love the work on this!
> I must admit though, that I (still!) don't know OSTree well enough to assess the details. Two questions:
> 
>     1. what's the relationship between the `/usr/lib/passwd` and `/etc/passwd` of an OSTree checkout (under the assumption that this is a fresh checkout that has never been booted or modified after the fact). I'm basically trying to understand why `/etc/passwd` is not just a copy of the `/usr/lib` one, and what might cause them to conflict.

If I understand it correctly, `/usr/etc/passwd` might actually contain additional user-defined values on top of what is present in `/usr/lib/passwd`. Now in a system, that has never been booted and it has been produced by osbuild-composer, I don't see why there would be any content in `/usr/etc/passwd`, but we can also produce commits on top of commits created by different tools, so we need to implement this just in case. (assuming I got it right, but @gicmo or @henrywang might have better understanding here)

> 
>     2. The merging algorithm does seem reasonable but is it something you and @gicmo came up with, if so, why is it the right thing to do, or is it mimicking some standard OSTree logic, if so why is that right and where can I see the "reference" documented/implemented?

It is not our solution, it comes from rpm-ostree:
https://github.com/coreos/rpm-ostree/blob/6b13f2596cdbd9a9bdc384c68cb31369edc65cab/rust/src/passwd.rs#L382
https://github.com/coreos/rpm-ostree/blob/6b13f2596cdbd9a9bdc384c68cb31369edc65cab/rust/src/nameservice/passwd.rs
Although our solution is a bit simplified.

Now I can't tell you why it is, for sure, the right thing to do, but here are couple of reasons why it could be:
 * you can only have one entry for each user
 * user defined values should take precedence over system default
 * if two users or groups have the same UID or GID, the error is in the checkout and it is not necessarily our job to make sure it doesn't happen

teg: Thanks for the answers @msehnout, makes sense to me!
msehnout: Thank you for reviews! I'll apply all the suggestions from @gicmo and commit a new version soon :-)
msehnout: > Cool! Thanks a lot! From a quick look it looks fine. One thing: we could move the helper functions to the ostree utils library (e.g. `osbuild/util/ostree.py`) and then add tests for them, so we can make sure that we deal with various cases properly (empty files, etcpp).

I'm not sure which of those file we expect to exist and if we want to enforce their existence. I assume `/usr/lib/passwd` is supposed to exist so I changed the code to enforce its presence, yet allow missing `/usr/etc/passwd` because I suppose that file does not get created in osbuild-composer (I've checked the `image-info` tool to see where we look for these files).
msehnout: Reproducer using full rhel-edge-container image types:
1. Create this container (containing the first commit): https://gist.github.com/msehnout/a49331f97aac4ace178cb7d4b2a844aa
2. Load the container into podman (`podman load`) and run it (reference: https://www.osbuild.org/guides/user-guide/edge-container+installer.html)
3. Create the second container which internally uses the first one as a parent: https://gist.github.com/msehnout/fa837303037e86754e466830a7c66b1e

For both cases, export the "ostree-tree" pipeline result. You can find the id in osbuild output:
```
...
⏱  Duration: 64s
build:    	af23098971a5191f1679f7425c10afec1a23911b9932d12f6c0207de359e1e4b
ostree-tree:	7ddd093b54bed2be2f0e9a9d69b11ff141351963050578d6877fd40e1368b6b9
ostree-commit:	f1bbb750b8d839713e949a7907eb5685aaf076b7f41d012e7d066209369af32e
container-tree:	6a63a458c532b71b53e543776c67da74d9d7e27bc57262981703fed6ffa38f6b
assembler:	6f50c396b2cfb641f849cfe267d23de5573ac27c8903b9f223fcaa66d95f26bf
```
now compare /usr/lib/group in both exported filesystems. If everything works, it should stay the same.
henrywang: Could you please `@` me, when this PR is ready for testing. I'll do manual test. Thanks.
champtar: Any plan to merge this ? osbuild is just unusable to build ostree updates without this PR
Here a diff between 2 commits, I was lucky it broke ssh else I wouldn't have noticed
```
# diff -u /sysroot/ostree/repo/objects/4a/daf8dc4a7159676b15a41456dddb8fd170d5a5d0ed0d842a39cc1beb18d8ff.file /sysroot/ostree/repo/objects/23/1dc5e50dab895af6d942fc3601ebc61d527aec4e214baf62ae6f8980622629.file
--- /sysroot/ostree/repo/objects/4a/daf8dc4a7159676b15a41456dddb8fd170d5a5d0ed0d842a39cc1beb18d8ff.file	1969-12-31 19:00:00.000000000 -0500
+++ /sysroot/ostree/repo/objects/23/1dc5e50dab895af6d942fc3601ebc61d527aec4e214baf62ae6f8980622629.file	1969-12-31 19:00:00.000000000 -0500
@@ -31,13 +31,9 @@
 systemd-coredump:x:997:
 systemd-resolve:x:193:
 tss:x:59:clevis
-polkitd:x:996:
-ssh_keys:x:995:
-setroubleshoot:x:994:
-cockpit-ws:x:993:
-cockpit-wsinstance:x:992:
-pcp:x:991:
-clevis:x:990:
-chrony:x:989:
+ssh_keys:x:996:
+polkitd:x:995:
+clevis:x:994:
+chrony:x:993:
 sshd:x:74:
-dnsmasq:x:988:
+dnsmasq:x:992:
```

gicmo: > Any plan to merge this ? osbuild is just unusable to build ostree updates without this PR

Yes, sorry, this will make it into osbuild 31.
champtar: Another possible bug that I'm not sure is addressed:
I ship an image without uid/gid 990, one of my user install an rpm (rpm-ostree install) that create an user/group with uid/gid 990, and create some files with uid/gid 990. Now when I ship an ostree update with another user that uses uid/gid 990, 2 users will have the same uid/gid.
An "easy" workaround would be, while composing an ostree, to assign system user uid/gid under 800 instead of 1000, and normal user uid superior to 1200 instead of 1000.
msehnout: @henrywang it is ready for testing. I ported the input to the host service framework and tested it using the script above.

Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 5af50ef7..ce3b215a 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -7,10 +7,13 @@ RUN dnf install -y \
     git \
     glibc-langpack-en \
     hostname \
+    ipython3 \
     jq \
     libguestfs-tools libguestfs \
     libosinfo \
+    lsof \
     make \
+    packit \
     qemu-img \
     qemu-system-x86 \
     osbuild \
@@ -18,6 +21,7 @@ RUN dnf install -y \
     podman \
     pylint \
     python3-autopep8 \
+    python3-boto3 \
     python3-devel \
     python3-docutils \
     python3-enchant \
diff --git a/.packit.yaml b/.packit.yaml
index bdffdf32..dddfdafa 100644
--- a/.packit.yaml
+++ b/.packit.yaml
@@ -9,6 +9,8 @@ synced_files:
 upstream_package_name: osbuild
 downstream_package_name: osbuild
 
+upstream_tag_template: v{version}
+
 actions:
   get-current-version: bash -c "git describe --tags --abbrev=0 | sed 's|v||'"
 
diff --git a/Makefile b/Makefile
index 223d0bef..c8f0179d 100644
--- a/Makefile
+++ b/Makefile
@@ -203,7 +203,8 @@ coverity-clean-all: coverity-clean
 # for linters and other tests on the source code.
 #
 
-TEST_MANIFESTS_MPP = $(wildcard $(SRCDIR)/test/data/manifests/*.mpp.json) \
+TEST_MANIFESTS_MPP = $(filter-out $(SRCDIR)/test/data/manifests/fedora-build.mpp.json, \
+             $(wildcard $(SRCDIR)/test/data/manifests/*.mpp.json)) \
 		     $(wildcard $(SRCDIR)/test/data/stages/*/*.mpp.json)
 TEST_MANIFESTS_GEN = $(TEST_MANIFESTS_MPP:%.mpp.json=%.json)
 
diff --git a/inputs/org.osbuild.ostree.checkout b/inputs/org.osbuild.ostree.checkout
new file mode 100755
index 00000000..c1ffee8a
--- /dev/null
+++ b/inputs/org.osbuild.ostree.checkout
@@ -0,0 +1,114 @@
+#!/usr/bin/python3
+"""
+Inputs for checkouts of ostree commits
+
+This input takes a number of commits and will check them out to a
+temporary directory. The name of the directory is the commit id.
+Internally uses `ostree checkout`
+"""
+
+
+import os
+import json
+import sys
+import subprocess
+
+from osbuild import inputs
+
+
+SCHEMA = """
+"additionalProperties": false,
+"required": ["type", "origin", "references"],
+"properties": {
+  "type": {
+    "enum": ["org.osbuild.ostree.checkout"]
+  },
+  "origin": {
+    "description": "The origin of the input",
+    "type": "string",
+    "enum": ["org.osbuild.source", "org.osbuild.pipeline"]
+  },
+  "references": {
+    "description": "Commit identifier to check out",
+    "oneOf": [{
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    }, {
+      "type": "object",
+      "additionalProperties": false,
+      "minProperties": 1,
+      "patternProperties": {
+        ".*": {
+          "type": "object",
+          "additionalProperties": false
+        }
+      }
+    }]
+  }
+}
+"""
+
+
+def ostree(*args, _input=None, **kwargs):
+    args = list(args) + [f'--{k}={v}' for k, v in kwargs.items()]
+    print("ostree " + " ".join(args), file=sys.stderr)
+    subprocess.run(["ostree"] + args,
+                   encoding="utf-8",
+                   stdout=sys.stderr,
+                   input=_input,
+                   check=True)
+
+
+def checkout(checksums, cache, output):
+    repo_cache = os.path.join(cache, "repo")
+
+    refs = []
+    for commit in checksums:
+        print(f"checkout {commit}", file=sys.stderr)
+
+        dest = os.path.join(output, commit)
+
+        ostree("checkout", commit, dest,
+               repo=repo_cache)
+
+        refs.append(commit)
+
+    return refs
+
+
+class OSTreeCheckoutInput(inputs.InputService):
+
+    def map(self, store, origin, refs, target, _options):
+
+        refs = []
+
+        if origin == "org.osbuild.pipeline":
+            for ref, options in refs.items():
+                source = store.read_tree(ref)
+                with open(os.path.join(source, "compose.json"), "r") as f:
+                    compose = json.load(f)
+                commit_id = compose["ostree-commit"]
+                refs.append(checkout({commit_id: options}, source, target))
+        else:
+            source = store.source("org.osbuild.ostree")
+            refs = checkout(refs, source, target)
+
+        reply = {
+          "path": target,
+          "data": {
+            "refs": {ref: {"path": ref} for ref in refs}
+          }
+        }
+
+        return reply
+
+
+def main():
+    service = OSTreeCheckoutInput.from_args(sys.argv[1:])
+    service.main()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/osbuild/util/ostree.py b/osbuild/util/ostree.py
index 88e0501c..ad409470 100644
--- a/osbuild/util/ostree.py
+++ b/osbuild/util/ostree.py
@@ -139,3 +139,50 @@ def deployment_path(root: PathLike, osname: str, ref: str, serial: int):
     sysroot = f"{stateroot}/deploy/{commit}.{serial}"
 
     return sysroot
+
+
+class PasswdLike:
+    """Representation of a file with structure like /etc/passwd
+
+    If each line in a file contains a key-value pair separated by the
+    first colon on the line, it can be considered "passwd"-like. This
+    class can parse the the list, manipulate it, and export it to file
+    again.
+    """
+    def __init__(self):
+        """Initialize an empty PasswdLike object"""
+        self.db = dict()
+
+    @classmethod
+    def from_file(cls, path: PathLike, allow_missing_file: bool=False):
+        """Initialize a PasswdLike object from an existing file"""
+        ret = cls()
+        if allow_missing_file:
+            if not os.path.isfile(path):
+                return ret
+
+        with open(path, "r") as p:
+            ret.db = cls._passwd_lines_to_dict(p.readlines())
+        return ret
+
+    def merge_with_file(self, path: PathLike, allow_missing_file: bool=False):
+        """Extend the database with entries from another file"""
+        if allow_missing_file:
+            if not os.path.isfile(path):
+                return
+
+        with open(path, "r") as p:
+            additional_passwd_dict = self._passwd_lines_to_dict(p.readlines())
+            for name, passwd_line in additional_passwd_dict.items():
+                if name not in self.db:
+                    self.db[name] = passwd_line
+
+    def dump_to_file(self, path: PathLike):
+        """Write the current database to a file"""
+        with open(path, "w") as p:
+            p.writelines(list(self.db.values()))
+
+    @staticmethod
+    def _passwd_lines_to_dict(lines):
+        """Take a list of passwd lines and produce a "name": "line" dictionary"""
+        return {line.split(':')[0]: line for line in lines}
diff --git a/stages/org.osbuild.ostree.passwd b/stages/org.osbuild.ostree.passwd
new file mode 100755
index 00000000..262df19e
--- /dev/null
+++ b/stages/org.osbuild.ostree.passwd
@@ -0,0 +1,92 @@
+#!/usr/bin/python3
+"""
+Populate buildroot with /etc/passwd and /etc/group from an OSTree checkout
+
+Using the OSTree checkout provided as in input, copy /usr/etc/passwd and
+/usr/lib/passwd, merge them and store the result into /etc/passwd in the
+buildroot. Do the same for /etc/group file.
+
+The use case for this stage is when one wants to preserve UIDs and GIDs
+which might change when the system is build from scratch. Creating these
+files before any RPMs (or other packages) are installed will prevent changes
+in UIDs and GIDs.
+"""
+
+import os
+import sys
+import subprocess
+
+import osbuild.api
+
+from osbuild.util.ostree import PasswdLike
+
+
+SCHEMA_2 = """
+"options": {
+  "additionalProperties": false
+},
+"inputs": {
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["commits"],
+  "properties": {
+    "commits": {
+      "type": "object",
+      "additionalProperties": true
+    }
+  }
+}
+"""
+
+
+def ostree(*args, _input=None, **kwargs):
+    args = list(args) + [f'--{k}={v}' for k, v in kwargs.items()]
+    print("ostree " + " ".join(args), file=sys.stderr)
+    subprocess.run(["ostree"] + args,
+                   encoding="utf-8",
+                   stdout=sys.stderr,
+                   input=_input,
+                   check=True)
+
+
+def parse_input(inputs):
+    commits = inputs["commits"]
+    source_root = commits["path"]
+    data = commits["data"]
+    refs = data["refs"]
+    assert refs, "Need at least one commit"
+    assert len(refs) == 1, "Only one commit is currently supported"
+    return source_root, refs
+
+
+# pylint: disable=too-many-statements
+def main(tree, inputs, _options):
+    source_root, refs = parse_input(inputs)
+
+    os.makedirs(os.path.join(tree, "etc"), exist_ok=True)
+    # Only once ref (commit) is currently supported, so this loop will run exactly once
+    for commit, data in refs.items():
+        ref = data.get("path", commit).lstrip("/")
+        checkout_root = os.path.join(source_root, ref)
+
+        # Merge /usr/etc/passwd with /usr/lib/passwd from the checkout and store it in the buildroot
+        # "tree" directory. Entries in /usr/etc/passwd have a precedence, but the file does not
+        # necessarily exist.
+        passwd = PasswdLike.from_file(os.path.join(checkout_root, "usr/etc/passwd"), allow_missing_file=True)
+        passwd.merge_with_file(os.path.join(checkout_root, "usr/lib/passwd"), allow_missing_file=False)
+        passwd.dump_to_file(os.path.join(tree, "etc/passwd"))
+
+        # Merge /usr/etc/group with /usr/lib/group from the checkout and store it in the buildroot
+        # "tree" directory. Entries in /usr/etc/group have a precedence, but the file does not
+        # necessarily exist.
+        passwd = PasswdLike.from_file(os.path.join(checkout_root, "usr/etc/group"), allow_missing_file=True)
+        passwd.merge_with_file(os.path.join(checkout_root, "usr/lib/group"), allow_missing_file=False)
+        passwd.dump_to_file(os.path.join(tree, "etc/group"))
+
+
+if __name__ == '__main__':
+    stage_args = osbuild.api.arguments()
+    r = main(stage_args["tree"],
+             stage_args["inputs"],
+             stage_args["options"])
+    sys.exit(r)


Test patch:
diff --git a/test/data/manifests/fedora-boot.mpp.json b/test/data/manifests/fedora-boot.mpp.json
index dac1745d..a67f11d0 100644
--- a/test/data/manifests/fedora-boot.mpp.json
+++ b/test/data/manifests/fedora-boot.mpp.json
@@ -8,9 +8,11 @@
   "pipeline": {
     "build": {
       "mpp-import-pipeline": {
-        "path": "f34-build.json"
+        "path": "fedora-build.mpp.json"
       },
-      "runner": "org.osbuild.fedora34"
+      "runner": {
+        "mpp-format-string": "org.osbuild.fedora{release}"
+      }
     },
     "stages": [
       {
@@ -28,8 +30,8 @@
           ],
           "mpp-depsolve": {
             "architecture": "$arch",
-            "module-platform-id": "f34",
-            "releasever": "f34",
+            "module-platform-id": "$releasever",
+            "releasever": "$releasever",
             "repos": [
               {
                 "id": "default",
diff --git a/test/data/manifests/fedora-build.mpp.json b/test/data/manifests/fedora-build.mpp.json
new file mode 100644
index 00000000..bfa46614
--- /dev/null
+++ b/test/data/manifests/fedora-build.mpp.json
@@ -0,0 +1,51 @@
+{
+  "pipeline": {
+    "stages": [
+      {
+        "name": "org.osbuild.rpm",
+        "options": {
+          "gpgkeys": [
+            "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBF1RVqsBEADWMBqYv/G1r4PwyiPQCfg5fXFGXV1FCZ32qMi9gLUTv1CX7rYy\nH4Inj93oic+lt1kQ0kQCkINOwQczOkm6XDkEekmMrHknJpFLwrTK4AS28bYF2RjL\nM+QJ/dGXDMPYsP0tkLvoxaHr9WTRq89A+AmONcUAQIMJg3JxXAAafBi2UszUUEPI\nU35MyufFt2ePd1k/6hVAO8S2VT72TxXSY7Ha4X2J0pGzbqQ6Dq3AVzogsnoIi09A\n7fYutYZPVVAEGRUqavl0th8LyuZShASZ38CdAHBMvWV4bVZghd/wDV5ev3LXUE0o\nitLAqNSeiDJ3grKWN6v0qdU0l3Ya60sugABd3xaE+ROe8kDCy3WmAaO51Q880ZA2\niXOTJFObqkBTP9j9+ZeQ+KNE8SBoiH1EybKtBU8HmygZvu8ZC1TKUyL5gwGUJt8v\nergy5Bw3Q7av520sNGD3cIWr4fBAVYwdBoZT8RcsnU1PP67NmOGFcwSFJ/LpiOMC\npZ1IBvjOC7KyKEZY2/63kjW73mB7OHOd18BHtGVkA3QAdVlcSule/z68VOAy6bih\nE6mdxP28D4INsts8w6yr4G+3aEIN8u0qRQq66Ri5mOXTyle+ONudtfGg3U9lgicg\nz6oVk17RT0jV9uL6K41sGZ1sH/6yTXQKagdAYr3w1ix2L46JgzC+/+6SSwARAQAB\ntDFGZWRvcmEgKDMyKSA8ZmVkb3JhLTMyLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJdUVarAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRBsEwJtEslE0LdAD/wKdAMtfzr7O2y06/sOPnrb3D39Y2DXbB8y0iEmRdBL29Bq\n5btxwmAka7JZRJVFxPsOVqZ6KARjS0/oCBmJc0jCRANFCtM4UjVHTSsxrJfuPkel\nvrlNE9tcR6OCRpuj/PZgUa39iifF/FTUfDgh4Q91xiQoLqfBxOJzravQHoK9VzrM\nNTOu6J6l4zeGzY/ocj6DpT+5fdUO/3HgGFNiNYPC6GVzeiA3AAVR0sCyGENuqqdg\nwUxV3BIht05M5Wcdvxg1U9x5I3yjkLQw+idvX4pevTiCh9/0u+4g80cT/21Cxsdx\n7+DVHaewXbF87QQIcOAing0S5QE67r2uPVxmWy/56TKUqDoyP8SNsV62lT2jutsj\nLevNxUky011g5w3bc61UeaeKrrurFdRs+RwBVkXmtqm/i6g0ZTWZyWGO6gJd+HWA\nqY1NYiq4+cMvNLatmA2sOoCsRNmE9q6jM/ESVgaH8hSp8GcLuzt9/r4PZZGl5CvU\neldOiD221u8rzuHmLs4dsgwJJ9pgLT0cUAsOpbMPI0JpGIPQ2SG6yK7LmO6HFOxb\nAkz7IGUt0gy1MzPTyBvnB+WgD1I+IQXXsJbhP5+d+d3mOnqsd6oDM/grKBzrhoUe\noNadc9uzjqKlOrmrdIR3Bz38SSiWlde5fu6xPqJdmGZRNjXtcyJlbSPVDIloxw==\n=QWRO\n-----END PGP PUBLIC KEY BLOCK-----\n"
+          ],
+          "mpp-depsolve": {
+            "architecture": "$arch",
+            "module-platform-id": "$releasever",
+            "repos": [
+              {
+                "id": "default",
+                "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/$releasever/$releasever-$arch-fedora-$snapshot/"
+              }
+            ],
+            "packages": [
+              "btrfs-progs",
+              "dnf",
+              "dosfstools",
+              "e2fsprogs",
+              "grub2-pc",
+              "policycoreutils",
+              "python3-iniparse",
+              "python3-pyyaml",
+              "qemu-img",
+              "rpm-ostree",
+              "selinux-policy-targeted",
+              "systemd",
+              "tar",
+              "xfsprogs",
+              "xz"
+            ]
+          }
+        }
+      },
+      {
+        "name": "org.osbuild.selinux",
+        "options": {
+          "file_contexts": "etc/selinux/targeted/contexts/files/file_contexts",
+          "labels": {
+            "/usr/bin/cp": "system_u:object_r:install_exec_t:s0",
+            "/usr/bin/tar": "system_u:object_r:install_exec_t:s0"
+          }
+        }
+      }
+    ]
+  }
+}
diff --git a/test/mod/test_util_ostree.py b/test/mod/test_util_ostree.py
index 4d39b90f..96584440 100644
--- a/test/mod/test_util_ostree.py
+++ b/test/mod/test_util_ostree.py
@@ -3,8 +3,10 @@
 #
 
 import json
-import unittest
+import os
 import subprocess
+import tempfile
+import unittest
 
 from osbuild.util import ostree
 
@@ -84,3 +86,64 @@ class TestObjectStore(unittest.TestCase):
 
         for p, v in params.items():
             self.assertEqual(v, js[p])
+
+
+class TestPasswdLike(unittest.TestCase):
+
+    def test_merge_passwd(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            primary_file_lines = [
+                "root:x:0:0:root:/root:/bin/bash\n",
+                "bin:x:1:1:bin:/bin:/sbin/nologin\n",
+                "daemon:x:2:2:daemon:/sbin:/sbin/nologin\n"
+            ]
+            secondary_file_lines = [
+                "daemon:x:9:9:daemon:/sbin:/sbin/nologin\n"
+                "lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin\n",
+                "sync:x:5:0:sync:/sbin:/bin/sync\n"
+            ]
+            result_file_lines = [
+                "root:x:0:0:root:/root:/bin/bash\n",
+                "bin:x:1:1:bin:/bin:/sbin/nologin\n",
+                "daemon:x:2:2:daemon:/sbin:/sbin/nologin\n",
+                "lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin\n",
+                "sync:x:5:0:sync:/sbin:/bin/sync\n"
+            ]
+            with open(os.path.join(tmpdir, "primary"), "w") as f:
+                f.writelines(primary_file_lines)
+            with open(os.path.join(tmpdir, "secondary"), "w") as f:
+                f.writelines(secondary_file_lines)
+
+            passwd = ostree.PasswdLike.from_file(os.path.join(tmpdir, "primary"))
+            passwd.merge_with_file(os.path.join(tmpdir, "secondary"))
+            passwd.dump_to_file(os.path.join(tmpdir, "result"))
+
+            with open(os.path.join(tmpdir, "result"), "r") as f:
+                self.assertEqual(sorted(f.readlines()), sorted(result_file_lines))
+
+    def test_merge_group(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            primary_file_lines = [
+                "root:x:0:\n",
+                "bin:x:1:\n"
+            ]
+            secondary_file_lines = [
+                "bin:x:4:\n",
+                "daemon:x:2:\n"
+            ]
+            result_file_lines = [
+                "root:x:0:\n",
+                "bin:x:1:\n",
+                "daemon:x:2:\n"
+            ]
+            with open(os.path.join(tmpdir, "primary"), "w") as f:
+                f.writelines(primary_file_lines)
+            with open(os.path.join(tmpdir, "secondary"), "w") as f:
+                f.writelines(secondary_file_lines)
+
+            passwd = ostree.PasswdLike.from_file(os.path.join(tmpdir, "primary"))
+            passwd.merge_with_file(os.path.join(tmpdir, "secondary"))
+            passwd.dump_to_file(os.path.join(tmpdir, "result"))
+
+            with open(os.path.join(tmpdir, "result"), "r") as f:
+                self.assertEqual(sorted(f.readlines()), sorted(result_file_lines))