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