Repository: systemd/mkosi

Issue:
Some mkosi created files left world writable in the image
It seems like mkosi sets the python process umask to 000 causing all files created with python to be world writable by default.

For example `/etc/machine-id` is unlinked and recreated with open in python code at the end of image creation but as permissions aren't changed after that, it is left as 0666 in the final image and systemd generation for new machine-id during boot won't change file permissions.

There are others like this but I haven't checked all options/distros for these as this is probably easier to check/fix just by finding all places where files are created in mkosi.

All of these can of course be hotfixed in mkosi.finalize script with find but it would be nicer if mkosi set proper permissions when creating files.

The hotfix for reference is `find "$BUILDROOT" -perm -o+w -type f -print -exec chmod o-w "{}" \;`


Discussion:

Resolving patch:
diff --git a/mkosi/__init__.py b/mkosi/__init__.py
--- a/mkosi/__init__.py
+++ b/mkosi/__init__.py
@@ -83,6 +83,7 @@
     roundup,
     run,
     run_workspace_command,
+    set_umask,
     should_compress_fs,
     should_compress_output,
     spawn,
@@ -476,9 +477,8 @@ def setup_workspace(args: CommandLineArguments) -> TempDir:
 
 
 def btrfs_subvol_create(path: Path, mode: int = 0o755) -> None:
-    m = os.umask(~mode & 0o7777)
-    run(["btrfs", "subvol", "create", path])
-    os.umask(m)
+    with set_umask(~mode & 0o7777):
+        run(["btrfs", "subvol", "create", path])
 
 
 def btrfs_subvol_delete(path: Path) -> None:
@@ -4167,12 +4167,22 @@ def save_cache(args: CommandLineArguments, root: Path, raw: Optional[str], cache
             shutil.move(cast(str, root), cache_path)  # typing bug, .move() accepts Path
 
 
-def _link_output(args: CommandLineArguments, oldpath: PathString, newpath: PathString) -> None:
+def _link_output(
+        args: CommandLineArguments,
+        oldpath: PathString,
+        newpath: PathString,
+        mode: int = 0o666,
+) -> None:
+
     assert oldpath is not None
     assert newpath is not None
 
-    os.chmod(oldpath, 0o666 & ~args.original_umask)
+    # Temporary files created by tempfile have mode trimmed to the user.
+    # After we are done writing files, adjust the mode to the default specified by umask.
+    os.chmod(oldpath, mode & ~args.original_umask)
+
     os.link(oldpath, newpath)
+
     if args.no_chown:
         return
 
@@ -4261,8 +4271,7 @@ def link_output_sshkey(args: CommandLineArguments, sshkey: Optional[SomeIO]) ->
     if sshkey:
         assert args.output_sshkey
         with complete_step("Linking private ssh key file…", f"Linked {path_relative_to_cwd(args.output_sshkey)}"):
-            _link_output(args, sshkey.name, args.output_sshkey)
-            os.chmod(args.output_sshkey, 0o600)
+            _link_output(args, sshkey.name, args.output_sshkey, mode=0o600)
 
 
 def link_output_split_root(args: CommandLineArguments, split_root: Optional[SomeIO]) -> None:
@@ -6125,7 +6134,10 @@ def load_args(args: argparse.Namespace) -> CommandLineArguments:
     if args.ssh_timeout < 0:
         die("--ssh-timeout must be >= 0")
 
-    args.original_umask = os.umask(0o000)
+    # We set a reasonable umask so that files that are created in the image
+    # will have reasonable permissions. We don't want those permissions to be
+    # influenced by the caller's umask which will be used only for output files.
+    args.original_umask = os.umask(0o022)
 
     # Let's define a fixed machine ID for all our build-time
     # runs. We'll strip it off the final image, but some build-time
diff --git a/mkosi/backend.py b/mkosi/backend.py
--- a/mkosi/backend.py
+++ b/mkosi/backend.py
@@ -40,6 +40,15 @@ def shell_join(cmd: Sequence[PathString]) -> str:
     return " ".join(shlex.quote(str(x)) for x in cmd)
 
 
+@contextlib.contextmanager
+def set_umask(mask: int) -> Generator[int, None, None]:
+    old = os.umask(mask)
+    try:
+        yield old
+    finally:
+        os.umask(old)
+
+
 def print_between_lines(s: str) -> None:
     size = os.get_terminal_size()
     print('-' * size.columns)


Test patch:
diff --git a/tests/test_backend.py b/tests/test_backend.py
--- a/tests/test_backend.py
+++ b/tests/test_backend.py
@@ -1,6 +1,8 @@
 # SPDX-License-Identifier: LGPL-2.1+
 
-from mkosi.backend import PackageType, Distribution
+import os
+
+from mkosi.backend import PackageType, Distribution, set_umask
 
 
 def test_distribution():
@@ -8,3 +10,15 @@ def test_distribution():
     assert Distribution.fedora is Distribution.fedora
     assert Distribution.fedora is not Distribution.debian
     assert str(Distribution.photon) == "photon"
+
+
+def test_set_umask():
+    with set_umask(0o767):
+        tmp1 = os.umask(0o777)
+        with set_umask(0o757):
+            tmp2 = os.umask(0o727)
+        tmp3 = os.umask(0o727)
+
+    assert tmp1 == 0o767
+    assert tmp2 == 0o757
+    assert tmp3 == 0o777