From 483f79cb3b94c8c7d176e748892a040c71132cb3 Mon Sep 17 00:00:00 2001
From: Chad Smith <chad.smith@canonical.com>
Date: Sat, 4 Feb 2023 13:37:02 -0700
Subject: [PATCH] netplan: keep custom strict perms when 50-cloud-init.yaml
 exists

Retain existing config file permissions when those permissions are
more strict than the default permissions set on
/etc/netplan/50-cloud-init.yaml.
---
 cloudinit/net/netplan.py                      |  5 ++
 .../unittests/test_distros/test_netconfig.py  | 74 ++++++++++++-------
 2 files changed, 53 insertions(+), 26 deletions(-)

diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
index 6bb2cb5..894b5db 100644
--- a/cloudinit/net/netplan.py
+++ b/cloudinit/net/netplan.py
@@ -237,6 +237,11 @@ class Renderer(renderer.Renderer):
             header += "\n"
 
         mode = 0o600 if features.NETPLAN_CONFIG_ROOT_READ_ONLY else 0o644
+        if os.path.exists(fpnplan):
+            current_mode = util.get_permissions(fpnplan)
+            if current_mode & mode == current_mode:
+                # preserve mode if existing perms are more strict than default
+                mode = current_mode
         util.write_file(fpnplan, header + content, mode=mode)
 
         if self.clean_default:
diff --git a/tests/unittests/test_distros/test_netconfig.py b/tests/unittests/test_distros/test_netconfig.py
index bae2dc4..97659c4 100644
--- a/tests/unittests/test_distros/test_netconfig.py
+++ b/tests/unittests/test_distros/test_netconfig.py
@@ -391,7 +391,6 @@ class TestNetCfgDistroUbuntuEni(TestNetCfgDistroBase):
         expected_cfgs = {
             self.eni_path(): V1_NET_CFG_OUTPUT,
         }
-        # ub_distro.apply_network_config(V1_NET_CFG, False)
         self._apply_and_verify_eni(self.distro.apply_network_config,
                                    V1_NET_CFG,
                                    expected_cfgs=expected_cfgs.copy())
@@ -411,8 +410,14 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase):
         self.distro = self._get_distro('ubuntu', renderers=['netplan'])
         self.devlist = ['eth0', 'lo']
 
-    def _apply_and_verify_netplan(self, apply_fn, config, expected_cfgs=None,
-                                  bringup=False):
+    def _apply_and_verify_netplan(
+        self,
+        apply_fn,
+        config,
+        expected_cfgs=None,
+        bringup=False,
+        previous_files=(),
+    ):
         if not expected_cfgs:
             raise ValueError('expected_cfg must not be None')
 
@@ -422,12 +427,12 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase):
             with mock.patch("cloudinit.net.netplan.get_devicelist",
                             return_value=self.devlist):
                 with self.reRooted(tmpd) as tmpd:
+                    for previous_path, content, mode in previous_files:
+                        util.write_file(previous_path, content, mode=mode)
                     apply_fn(config, bringup)
 
         results = dir2dict(tmpd)
-
-        mode = 0o600 if features.NETPLAN_CONFIG_ROOT_READ_ONLY else 0o644
-        for cfgpath, expected in expected_cfgs.items():
+        for cfgpath, expected, mode in expected_cfgs:
             print("----------")
             print(expected)
             print("^^^^ expected | rendered VVVVVVV")
@@ -440,39 +445,56 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase):
         return '/etc/netplan/50-cloud-init.yaml'
 
     def test_apply_network_config_v1_to_netplan_ub(self):
-        expected_cfgs = {
-            self.netplan_path(): V1_TO_V2_NET_CFG_OUTPUT,
-        }
+        expected_cfgs = (
+            (self.netplan_path(), V1_TO_V2_NET_CFG_OUTPUT, 0o600),
+        )
 
-        # ub_distro.apply_network_config(V1_NET_CFG, False)
         self._apply_and_verify_netplan(self.distro.apply_network_config,
                                        V1_NET_CFG,
-                                       expected_cfgs=expected_cfgs.copy())
+                                       expected_cfgs=expected_cfgs)
 
     def test_apply_network_config_v1_ipv6_to_netplan_ub(self):
-        expected_cfgs = {
-            self.netplan_path(): V1_TO_V2_NET_CFG_IPV6_OUTPUT,
-        }
+        expected_cfgs = (
+            (self.netplan_path(), V1_TO_V2_NET_CFG_IPV6_OUTPUT, 0o600),
+        )
 
-        # ub_distro.apply_network_config(V1_NET_CFG_IPV6, False)
         self._apply_and_verify_netplan(self.distro.apply_network_config,
                                        V1_NET_CFG_IPV6,
-                                       expected_cfgs=expected_cfgs.copy())
+                                       expected_cfgs=expected_cfgs)
 
     def test_apply_network_config_v2_passthrough_ub(self):
-        expected_cfgs = {
-            self.netplan_path(): V2_TO_V2_NET_CFG_OUTPUT,
-        }
-        # ub_distro.apply_network_config(V2_NET_CFG, False)
+        expected_cfgs = (
+            (self.netplan_path(), V2_TO_V2_NET_CFG_OUTPUT, 0o600),
+        )
         self._apply_and_verify_netplan(self.distro.apply_network_config,
                                        V2_NET_CFG,
-                                       expected_cfgs=expected_cfgs.copy())
+                                       expected_cfgs=expected_cfgs)
+
+    def test_apply_network_config_v2_passthrough_retain_orig_perms(self):
+        """Custom permissions on existing netplan is kept when more strict."""
+        expected_cfgs = (
+                 (self.netplan_path(), V2_TO_V2_NET_CFG_OUTPUT, 0o640),
+             )
+        with mock.patch.object(
+            features, "NETPLAN_CONFIG_ROOT_READ_ONLY", False
+        ):
+            # When NETPLAN_CONFIG_ROOT_READ_ONLY is False default perms are 644
+            # we keep 640 because it's more strict.
+            # 1640 is used to assert sticky bit preserved across write
+            self._apply_and_verify_netplan(
+                self.distro.apply_network_config,
+                V2_NET_CFG,
+                expected_cfgs=expected_cfgs,
+                previous_files=(
+                    ("/etc/netplan/50-cloud-init.yaml", "a", 0o640),
+                ),
+            )
 
     def test_apply_network_config_v2_passthrough_ub_old_behavior(self):
         """Kinetic and earlier have 50-cloud-init.yaml world-readable"""
-        expected_cfgs = {
-            self.netplan_path(): V2_TO_V2_NET_CFG_OUTPUT,
-        }
+        expected_cfgs = (
+            (self.netplan_path(), V2_TO_V2_NET_CFG_OUTPUT, 0o644),
+        )
         # ub_distro.apply_network_config(V2_NET_CFG, False)
         with mock.patch.object(
             features, "NETPLAN_CONFIG_ROOT_READ_ONLY", False
@@ -480,7 +502,7 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase):
             self._apply_and_verify_netplan(
                 self.distro.apply_network_config,
                 V2_NET_CFG,
-                expected_cfgs=expected_cfgs.copy(),
+                expected_cfgs=expected_cfgs,
             )
 
 class TestNetCfgDistroRedhat(TestNetCfgDistroBase):
@@ -771,7 +793,6 @@ class TestNetCfgDistroArch(TestNetCfgDistroBase):
                 """),
         }
 
-        # ub_distro.apply_network_config(V1_NET_CFG, False)
         self._apply_and_verify(self.distro.apply_network_config,
                                V1_NET_CFG,
                                expected_cfgs=expected_cfgs.copy(),
@@ -920,6 +941,7 @@ class TestNetCfgDistroPhoton(TestNetCfgDistroBase):
 
 
 def get_mode(path, target=None):
+    # Mask upper st_mode bits like S_IFREG bit preserve sticky and isuid/osgid
     return os.stat(subp.target_path(target, path)).st_mode & 0o777
 
 # vi: ts=4 expandtab
-- 
2.33.0

