3f51ca
From dda1087a2e6e0f5fcb8623983f5296b8a426cfc5 Mon Sep 17 00:00:00 2001
3f51ca
From: Christian Heimes <cheimes@redhat.com>
3f51ca
Date: Fri, 8 Dec 2017 12:38:41 +0100
3f51ca
Subject: [PATCH] Add safe DirectiveSetter context manager
3f51ca
3f51ca
installutils.set_directive() is both inefficient and potentially
3f51ca
dangerous. It does not ensure that the whole file is written and
3f51ca
properly synced to disk. In worst case it could lead to partially
3f51ca
written or destroyed config files.
3f51ca
3f51ca
The new DirectiveSetter context manager wraps everything under an easy
3f51ca
to use interface.
3f51ca
3f51ca
https://pagure.io/freeipa/issue/7312
3f51ca
3f51ca
Signed-off-by: Christian Heimes <cheimes@redhat.com>
3f51ca
Reviewed-By: Florence Blanc-Renaud <frenaud@redhat.com>
3f51ca
---
3f51ca
 ipaserver/install/cainstance.py                    | 109 ++++++++++-----------
3f51ca
 ipaserver/install/installutils.py                  |  87 +++++++++++++++-
3f51ca
 .../test_install/test_installutils.py              |  67 +++++++++++++
3f51ca
 3 files changed, 205 insertions(+), 58 deletions(-)
3f51ca
3f51ca
diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py
3f51ca
index dd410d1c97cb4b27d35086bb2f511c42c02d022f..20635eae22268ff72de73b8b9c430050114bb45b 100644
3f51ca
--- a/ipaserver/install/cainstance.py
3f51ca
+++ b/ipaserver/install/cainstance.py
3f51ca
@@ -908,62 +908,61 @@ class CAInstance(DogtagInstance):
3f51ca
         https://access.redhat.com/knowledge/docs/en-US/Red_Hat_Certificate_System/8.0/html/Admin_Guide/Setting_up_Publishing.html
3f51ca
         """
3f51ca
 
3f51ca
-        def put(k, v):
3f51ca
-            installutils.set_directive(
3f51ca
-                paths.CA_CS_CFG_PATH, k, v, quotes=False, separator='=')
3f51ca
+        with installutils.DirectiveSetter(paths.CA_CS_CFG_PATH,
3f51ca
+                                          quotes=False, separator='=') as ds:
3f51ca
 
3f51ca
-        # Enable file publishing, disable LDAP
3f51ca
-        put('ca.publish.enable', 'true')
3f51ca
-        put('ca.publish.ldappublish.enable', 'false')
3f51ca
-
3f51ca
-        # Create the file publisher, der only, not b64
3f51ca
-        put('ca.publish.publisher.impl.FileBasedPublisher.class',
3f51ca
-            'com.netscape.cms.publish.publishers.FileBasedPublisher')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.crlLinkExt',
3f51ca
-            'bin')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.directory',
3f51ca
-            self.prepare_crl_publish_dir())
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.latestCrlLink',
3f51ca
-            'true')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.pluginName',
3f51ca
-            'FileBasedPublisher')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.timeStamp',
3f51ca
-            'LocalTime')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.zipCRLs',
3f51ca
-            'false')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.zipLevel', '9')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.Filename.b64',
3f51ca
-            'false')
3f51ca
-        put('ca.publish.publisher.instance.FileBaseCRLPublisher.Filename.der',
3f51ca
-            'true')
3f51ca
-
3f51ca
-        # The publishing rule
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.enable', 'true')
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.mapper', 'NoMap')
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.pluginName', 'Rule')
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.predicate', '')
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.publisher',
3f51ca
-            'FileBaseCRLPublisher')
3f51ca
-        put('ca.publish.rule.instance.FileCrlRule.type', 'crl')
3f51ca
-
3f51ca
-        # Now disable LDAP publishing
3f51ca
-        put('ca.publish.rule.instance.LdapCaCertRule.enable', 'false')
3f51ca
-        put('ca.publish.rule.instance.LdapCrlRule.enable', 'false')
3f51ca
-        put('ca.publish.rule.instance.LdapUserCertRule.enable', 'false')
3f51ca
-        put('ca.publish.rule.instance.LdapXCertRule.enable', 'false')
3f51ca
-
3f51ca
-        # If we are the initial master then we are the CRL generator, otherwise
3f51ca
-        # we point to that master for CRLs.
3f51ca
-        if not self.clone:
3f51ca
-            # These next two are defaults, but I want to be explicit that the
3f51ca
-            # initial master is the CRL generator.
3f51ca
-            put('ca.crl.MasterCRL.enableCRLCache', 'true')
3f51ca
-            put('ca.crl.MasterCRL.enableCRLUpdates', 'true')
3f51ca
-            put('ca.listenToCloneModifications', 'true')
3f51ca
-        else:
3f51ca
-            put('ca.crl.MasterCRL.enableCRLCache', 'false')
3f51ca
-            put('ca.crl.MasterCRL.enableCRLUpdates', 'false')
3f51ca
-            put('ca.listenToCloneModifications', 'false')
3f51ca
+            # Enable file publishing, disable LDAP
3f51ca
+            ds.set('ca.publish.enable', 'true')
3f51ca
+            ds.set('ca.publish.ldappublish.enable', 'false')
3f51ca
+
3f51ca
+            # Create the file publisher, der only, not b64
3f51ca
+            ds.set(
3f51ca
+                'ca.publish.publisher.impl.FileBasedPublisher.class',
3f51ca
+                'com.netscape.cms.publish.publishers.FileBasedPublisher'
3f51ca
+            )
3f51ca
+            prefix = 'ca.publish.publisher.instance.FileBaseCRLPublisher.'
3f51ca
+            ds.set(prefix + 'crlLinkExt', 'bin')
3f51ca
+            ds.set(prefix + 'directory', self.prepare_crl_publish_dir())
3f51ca
+            ds.set(prefix + 'latestCrlLink', 'true')
3f51ca
+            ds.set(prefix + 'pluginName', 'FileBasedPublisher')
3f51ca
+            ds.set(prefix + 'timeStamp', 'LocalTime')
3f51ca
+            ds.set(prefix + 'zipCRLs', 'false')
3f51ca
+            ds.set(prefix + 'zipLevel', '9')
3f51ca
+            ds.set(prefix + 'Filename.b64', 'false')
3f51ca
+            ds.set(prefix + 'Filename.der', 'true')
3f51ca
+
3f51ca
+            # The publishing rule
3f51ca
+            ds.set('ca.publish.rule.instance.FileCrlRule.enable', 'true')
3f51ca
+            ds.set('ca.publish.rule.instance.FileCrlRule.mapper', 'NoMap')
3f51ca
+            ds.set('ca.publish.rule.instance.FileCrlRule.pluginName', 'Rule')
3f51ca
+            ds.set('ca.publish.rule.instance.FileCrlRule.predicate', '')
3f51ca
+            ds.set(
3f51ca
+                'ca.publish.rule.instance.FileCrlRule.publisher',
3f51ca
+                'FileBaseCRLPublisher'
3f51ca
+            )
3f51ca
+            ds.set('ca.publish.rule.instance.FileCrlRule.type', 'crl')
3f51ca
+
3f51ca
+            # Now disable LDAP publishing
3f51ca
+            ds.set('ca.publish.rule.instance.LdapCaCertRule.enable', 'false')
3f51ca
+            ds.set('ca.publish.rule.instance.LdapCrlRule.enable', 'false')
3f51ca
+            ds.set(
3f51ca
+                'ca.publish.rule.instance.LdapUserCertRule.enable',
3f51ca
+                'false'
3f51ca
+            )
3f51ca
+            ds.set('ca.publish.rule.instance.LdapXCertRule.enable', 'false')
3f51ca
+
3f51ca
+            # If we are the initial master then we are the CRL generator,
3f51ca
+            # otherwise we point to that master for CRLs.
3f51ca
+            if not self.clone:
3f51ca
+                # These next two are defaults, but I want to be explicit
3f51ca
+                # that the initial master is the CRL generator.
3f51ca
+                ds.set('ca.crl.MasterCRL.enableCRLCache', 'true')
3f51ca
+                ds.set('ca.crl.MasterCRL.enableCRLUpdates', 'true')
3f51ca
+                ds.set('ca.listenToCloneModifications', 'true')
3f51ca
+            else:
3f51ca
+                ds.set('ca.crl.MasterCRL.enableCRLCache', 'false')
3f51ca
+                ds.set('ca.crl.MasterCRL.enableCRLUpdates', 'false')
3f51ca
+                ds.set('ca.listenToCloneModifications', 'false')
3f51ca
 
3f51ca
     def uninstall(self):
3f51ca
         # just eat state
3f51ca
diff --git a/ipaserver/install/installutils.py b/ipaserver/install/installutils.py
3f51ca
index b56cf591496c679e5fcf3e94f458c286216eb1e4..08e098f8656c8fe61a87fb046c01e1487c25da7f 100644
3f51ca
--- a/ipaserver/install/installutils.py
3f51ca
+++ b/ipaserver/install/installutils.py
3f51ca
@@ -24,6 +24,7 @@ import errno
3f51ca
 import socket
3f51ca
 import getpass
3f51ca
 import gssapi
3f51ca
+import io
3f51ca
 import ldif
3f51ca
 import os
3f51ca
 import re
3f51ca
@@ -31,6 +32,7 @@ import fileinput
3f51ca
 import sys
3f51ca
 import tempfile
3f51ca
 import shutil
3f51ca
+import stat  # pylint: disable=bad-python3-import
3f51ca
 import traceback
3f51ca
 import textwrap
3f51ca
 from contextlib import contextmanager
3f51ca
@@ -428,6 +430,82 @@ def unquote_directive_value(value, quote_char):
3f51ca
     return unescaped_value
3f51ca
 
3f51ca
 
3f51ca
+_SENTINEL = object()
3f51ca
+
3f51ca
+
3f51ca
+class DirectiveSetter(object):
3f51ca
+    """Safe directive setter
3f51ca
+
3f51ca
+    with DirectiveSetter('/path/to/conf') as ds:
3f51ca
+        ds.set(key, value)
3f51ca
+    """
3f51ca
+    def __init__(self, filename, quotes=True, separator=' '):
3f51ca
+        self.filename = os.path.abspath(filename)
3f51ca
+        self.quotes = quotes
3f51ca
+        self.separator = separator
3f51ca
+        self.lines = None
3f51ca
+        self.stat = None
3f51ca
+
3f51ca
+    def __enter__(self):
3f51ca
+        with io.open(self.filename) as f:
3f51ca
+            self.stat = os.fstat(f.fileno())
3f51ca
+            self.lines = list(f)
3f51ca
+        return self
3f51ca
+
3f51ca
+    def __exit__(self, exc_type, exc_val, exc_tb):
3f51ca
+        if exc_type is not None:
3f51ca
+            # something went wrong, reset
3f51ca
+            self.lines = None
3f51ca
+            self.stat = None
3f51ca
+            return
3f51ca
+
3f51ca
+        directory, prefix = os.path.split(self.filename)
3f51ca
+        # use tempfile in same directory to have atomic rename
3f51ca
+        fd, name = tempfile.mkstemp(prefix=prefix, dir=directory, text=True)
3f51ca
+        with io.open(fd, mode='w', closefd=True) as f:
3f51ca
+            for line in self.lines:
3f51ca
+                if not isinstance(line, six.text_type):
3f51ca
+                    line = line.decode('utf-8')
3f51ca
+                f.write(line)
3f51ca
+            self.lines = None
3f51ca
+            os.fchmod(f.fileno(), stat.S_IMODE(self.stat.st_mode))
3f51ca
+            os.fchown(f.fileno(), self.stat.st_uid, self.stat.st_gid)
3f51ca
+            self.stat = None
3f51ca
+            # flush and sync tempfile inode
3f51ca
+            f.flush()
3f51ca
+            os.fsync(f.fileno())
3f51ca
+
3f51ca
+        # rename file and sync directory inode
3f51ca
+        os.rename(name, self.filename)
3f51ca
+        dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
3f51ca
+        try:
3f51ca
+            os.fsync(dirfd)
3f51ca
+        finally:
3f51ca
+            os.close(dirfd)
3f51ca
+
3f51ca
+    def set(self, directive, value, quotes=_SENTINEL, separator=_SENTINEL):
3f51ca
+        """Set a single directive
3f51ca
+        """
3f51ca
+        if quotes is _SENTINEL:
3f51ca
+            quotes = self.quotes
3f51ca
+        if separator is _SENTINEL:
3f51ca
+            separator = self.separator
3f51ca
+        # materialize lines
3f51ca
+        # set_directive_lines() modify item, shrink or enlage line count
3f51ca
+        self.lines = list(set_directive_lines(
3f51ca
+            quotes, separator, directive, value, self.lines
3f51ca
+        ))
3f51ca
+
3f51ca
+    def setitems(self, items):
3f51ca
+        """Set multiple directives from a dict or list with key/value pairs
3f51ca
+        """
3f51ca
+        if isinstance(items, dict):
3f51ca
+            # dict-like, use sorted for stable order
3f51ca
+            items = sorted(items.items())
3f51ca
+        for k, v in items:
3f51ca
+            self.set(k, v)
3f51ca
+
3f51ca
+
3f51ca
 def set_directive(filename, directive, value, quotes=True, separator=' '):
3f51ca
     """Set a name/value pair directive in a configuration file.
3f51ca
 
3f51ca
@@ -447,8 +525,10 @@ def set_directive(filename, directive, value, quotes=True, separator=' '):
3f51ca
     st = os.stat(filename)
3f51ca
     with open(filename, 'r') as f:
3f51ca
         lines = list(f)  # read the whole file
3f51ca
-        new_lines = set_directive_lines(
3f51ca
-            quotes, separator, directive, value, lines)
3f51ca
+        # materialize new list
3f51ca
+        new_lines = list(set_directive_lines(
3f51ca
+            quotes, separator, directive, value, lines
3f51ca
+        ))
3f51ca
     with open(filename, 'w') as f:
3f51ca
         # don't construct the whole string; write line-wise
3f51ca
         for line in new_lines:
3f51ca
@@ -472,8 +552,9 @@ def set_directive_lines(quotes, separator, k, v, lines):
3f51ca
         new_line = ''.join([k, separator, v_quoted, '\n'])
3f51ca
 
3f51ca
     found = False
3f51ca
+    matcher = re.compile(r'\s*{}'.format(re.escape(k + separator)))
3f51ca
     for line in lines:
3f51ca
-        if re.match(r'\s*{}'.format(re.escape(k + separator)), line):
3f51ca
+        if matcher.match(line):
3f51ca
             found = True
3f51ca
             if v is not None:
3f51ca
                 yield new_line
3f51ca
diff --git a/ipatests/test_ipaserver/test_install/test_installutils.py b/ipatests/test_ipaserver/test_install/test_installutils.py
3f51ca
index cc8fd3cf3f82c2d9af48287f506a566ffbfc39f6..3c992c9ab06ddc3af557329de81debe704b0fb16 100644
3f51ca
--- a/ipatests/test_ipaserver/test_install/test_installutils.py
3f51ca
+++ b/ipatests/test_ipaserver/test_install/test_installutils.py
3f51ca
@@ -3,8 +3,11 @@
3f51ca
 #
3f51ca
 
3f51ca
 import os
3f51ca
+import shutil
3f51ca
 import tempfile
3f51ca
 
3f51ca
+import pytest
3f51ca
+
3f51ca
 from ipaserver.install import installutils
3f51ca
 
3f51ca
 EXAMPLE_CONFIG = [
3f51ca
@@ -13,6 +16,17 @@ EXAMPLE_CONFIG = [
3f51ca
 ]
3f51ca
 
3f51ca
 
3f51ca
+@pytest.fixture
3f51ca
+def tempdir(request):
3f51ca
+    tempdir = tempfile.mkdtemp()
3f51ca
+
3f51ca
+    def fin():
3f51ca
+        shutil.rmtree(tempdir)
3f51ca
+
3f51ca
+    request.addfinalizer(fin)
3f51ca
+    return tempdir
3f51ca
+
3f51ca
+
3f51ca
 class test_set_directive_lines(object):
3f51ca
     def test_remove_directive(self):
3f51ca
         lines = installutils.set_directive_lines(
3f51ca
@@ -55,3 +69,56 @@ class test_set_directive(object):
3f51ca
 
3f51ca
         finally:
3f51ca
             os.remove(filename)
3f51ca
+
3f51ca
+
3f51ca
+def test_directivesetter(tempdir):
3f51ca
+    filename = os.path.join(tempdir, 'example.conf')
3f51ca
+    with open(filename, 'w') as f:
3f51ca
+        for line in EXAMPLE_CONFIG:
3f51ca
+            f.write(line)
3f51ca
+
3f51ca
+    ds = installutils.DirectiveSetter(filename)
3f51ca
+    assert ds.lines is None
3f51ca
+    with ds:
3f51ca
+        assert ds.lines == EXAMPLE_CONFIG
3f51ca
+        ds.set('foo', '3')  # quoted, space separated, doesn't change 'foo='
3f51ca
+        ds.set('foobar', None, separator='=')  # remove
3f51ca
+        ds.set('baz', '4', False, '=')  # add
3f51ca
+        ds.setitems([
3f51ca
+            ('list1', 'value1'),
3f51ca
+            ('list2', 'value2'),
3f51ca
+        ])
3f51ca
+        ds.setitems({
3f51ca
+            'dict1': 'value1',
3f51ca
+            'dict2': 'value2',
3f51ca
+        })
3f51ca
+
3f51ca
+    with open(filename, 'r') as f:
3f51ca
+        lines = list(f)
3f51ca
+
3f51ca
+    assert lines == [
3f51ca
+        'foo=1\n',
3f51ca
+        'foo "3"\n',
3f51ca
+        'baz=4\n',
3f51ca
+        'list1 "value1"\n',
3f51ca
+        'list2 "value2"\n',
3f51ca
+        'dict1 "value1"\n',
3f51ca
+        'dict2 "value2"\n',
3f51ca
+    ]
3f51ca
+
3f51ca
+    with installutils.DirectiveSetter(filename, True, '=') as ds:
3f51ca
+        ds.set('foo', '4')  # doesn't change 'foo '
3f51ca
+
3f51ca
+    with open(filename, 'r') as f:
3f51ca
+        lines = list(f)
3f51ca
+
3f51ca
+    assert lines == [
3f51ca
+        'foo="4"\n',
3f51ca
+        'foo "3"\n',
3f51ca
+        'baz=4\n',
3f51ca
+        'list1 "value1"\n',
3f51ca
+        'list2 "value2"\n',
3f51ca
+        'dict1 "value1"\n',
3f51ca
+        'dict2 "value2"\n',
3f51ca
+
3f51ca
+    ]
3f51ca
-- 
3f51ca
2.13.6
3f51ca