summaryrefslogtreecommitdiffstats
path: root/ipapython/directivesetter.py
blob: b03c580eeb91c577dbe719384b3cd41c0fd7a14b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#
# Copyright (C) 2018 FreeIPA Contributors see COPYING for license
#

import io
import os
import re
import stat
import tempfile

from ipapython.ipautil import unescape_seq, escape_seq

_SENTINEL = object()


class DirectiveSetter:
    """Safe directive setter

    with DirectiveSetter('/path/to/conf') as ds:
        ds.set(key, value)
    """
    def __init__(self, filename, quotes=True, separator=' ', comment='#'):
        self.filename = os.path.abspath(filename)
        self.quotes = quotes
        self.separator = separator
        self.comment = comment
        self.lines = None
        self.stat = None

    def __enter__(self):
        with io.open(self.filename) as f:
            self.stat = os.fstat(f.fileno())
            self.lines = list(f)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # something went wrong, reset
            self.lines = None
            self.stat = None
            return

        directory, prefix = os.path.split(self.filename)
        # use tempfile in same directory to have atomic rename
        fd, name = tempfile.mkstemp(prefix=prefix, dir=directory, text=True)
        with io.open(fd, mode='w', closefd=True) as f:
            for line in self.lines:
                if not isinstance(line, str):
                    line = line.decode('utf-8')
                f.write(line)
            self.lines = None
            os.fchmod(f.fileno(), stat.S_IMODE(self.stat.st_mode))
            os.fchown(f.fileno(), self.stat.st_uid, self.stat.st_gid)
            self.stat = None
            # flush and sync tempfile inode
            f.flush()
            os.fsync(f.fileno())

        # rename file and sync directory inode
        os.rename(name, self.filename)
        dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
        try:
            os.fsync(dirfd)
        finally:
            os.close(dirfd)

    def set(self, directive, value, quotes=_SENTINEL, separator=_SENTINEL,
            comment=_SENTINEL):
        """Set a single directive
        """
        if quotes is _SENTINEL:
            quotes = self.quotes
        if separator is _SENTINEL:
            separator = self.separator
        if comment is _SENTINEL:
            comment = self.comment
        # materialize lines
        # set_directive_lines() modify item, shrink or enlage line count
        self.lines = list(set_directive_lines(
            quotes, separator, directive, value, self.lines, comment
        ))

    def setitems(self, items):
        """Set multiple directives from a dict or list with key/value pairs
        """
        if isinstance(items, dict):
            # dict-like, use sorted for stable order
            items = sorted(items.items())
        for k, v in items:
            self.set(k, v)


def set_directive(filename, directive, value, quotes=True, separator=' ',
                  comment='#'):
    """Set a name/value pair directive in a configuration file.

    A value of None means to drop the directive.

    Does not tolerate (or put) spaces around the separator.

    :param filename: input filename
    :param directive: directive name
    :param value: value of the directive
    :param quotes: whether to quote `value` in double quotes. If true, then
        any existing double quotes are first escaped to avoid
        unparseable directives.
    :param separator: character serving as separator between directive and
        value.  Correct value required even when dropping a directive.
    :param comment: comment character for the file to keep new values near
                    their commented-out counterpart
    """
    st = os.stat(filename)
    with open(filename, 'r') as f:
        lines = list(f)  # read the whole file
        # materialize new list
        new_lines = list(set_directive_lines(
            quotes, separator, directive, value, lines, comment
        ))
    with open(filename, 'w') as f:
        # don't construct the whole string; write line-wise
        for line in new_lines:
            f.write(line)
    os.chown(filename, st.st_uid, st.st_gid)  # reset perms


def set_directive_lines(quotes, separator, k, v, lines, comment):
    """Set a name/value pair in a configuration (iterable of lines).

    Replaces the value of the key if found, otherwise adds it at
    end.  If value is ``None``, remove the key if found.

    Takes an iterable of lines (with trailing newline).
    Yields lines (with trailing newline).

    """
    new_line = ""
    if v is not None:
        v_quoted = quote_directive_value(v, '"') if quotes else v
        new_line = ''.join([k, separator, v_quoted, '\n'])

    # Special case: consider space as "white space" so tabs are allowed
    if separator == ' ':
        separator = '[ \t]+'

    found = False
    addnext = False  # add on next line, found a comment
    matcher = re.compile(r'\s*{}\s*{}'.format(re.escape(k), separator))
    cmatcher = re.compile(r'\s*{}\s*{}\s*{}'.format(comment,
                                                    re.escape(k), separator))
    for line in lines:
        if matcher.match(line):
            found = True
            addnext = False
            if v is not None:
                yield new_line
        elif addnext:
            found = True
            addnext = False
            yield new_line
            yield line
        elif cmatcher.match(line):
            addnext = True
            yield line
        else:
            yield line

    if not found and v is not None:
        yield new_line


def get_directive(filename, directive, separator=' '):
    """
    A rather inefficient way to get a configuration directive.

    :param filename: input filename
    :param directive: directive name
    :param separator: separator between directive and value

    :returns: The (unquoted) value if the directive was found, None otherwise
    """
    # Special case: consider space as "white space" so tabs are allowed
    if separator == ' ':
        separator = '[ \t]+'

    result = None
    with open(filename, "r") as fd:
        for line in fd:
            if line.lstrip().startswith(directive):
                line = line.strip()

                match = re.match(
                    r'{}\s*{}\s*(.*)'.format(directive, separator), line)
                if match:
                    value = match.group(1)
                else:
                    raise ValueError("Malformed directive: {}".format(line))

                result = unquote_directive_value(value.strip(), '"')
                result = result.strip(' ')
                break
    return result


def quote_directive_value(value, quote_char):
    """Quote a directive value
    :param value: string to quote
    :param quote_char: character which is used for quoting. All prior
        occurences will be escaped before quoting to avoid unparseable value.
    :returns: processed value
    """
    if value.startswith(quote_char) and value.endswith(quote_char):
        return value

    return "{quote}{value}{quote}".format(
        quote=quote_char,
        value="".join(escape_seq(quote_char, value))
    )


def unquote_directive_value(value, quote_char):
    """Unquote a directive value
    :param value: string to unquote
    :param quote_char: character to strip. All escaped occurences of
        `quote_char` will be uncescaped during processing
    :returns: processed value
    """
    unescaped_value = "".join(unescape_seq(quote_char, value))
    if (unescaped_value.startswith(quote_char) and
            unescaped_value.endswith(quote_char)):
        return unescaped_value[1:-1]

    return unescaped_value