1#!/usr/bin/env python
2#
3# Licensed to the Apache Software Foundation (ASF) under one or more
4# contributor license agreements.  See the NOTICE file distributed with
5# this work for additional information regarding copyright ownership.
6# The ASF licenses this file to You under the Apache License, Version 2.0
7# (the "License"); you may not use this file except in compliance with
8# the License.  You may obtain a copy of the License at
9#
10#     http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18
19"""Subversion pre-commit hook script that runs user configured commands
20to validate files in the commit and reject the commit if the commands
21exit with a non-zero exit code.  The script expects a validate-files.conf
22file placed in the conf dir under the repo the commit is for.
23
24Note: As changed file paths $FILE are always represented as a Unicode (Py3)
25      or UTF-8 (Py2) strings, you might need to set apropriate locale and
26      PYTHONIOENCODING environment variable for this script and
27      commands to handle non-ascii path and command outputs, especially
28      you want to use svnlook cat command to inspect file contents."""
29
30import sys
31import os
32import subprocess
33import fnmatch
34
35# Deal with the rename of ConfigParser to configparser in Python3
36try:
37    # Python >= 3.0
38    import configparser
39    ConfigParser = configparser.ConfigParser
40except ImportError:
41    # Python < 3.0
42    import ConfigParser as configparser
43    ConfigParser = configparser.SafeConfigParser
44
45class Config(ConfigParser):
46    """Superclass of SafeConfigParser with some customizations
47    for this script"""
48    def optionxform(self, option):
49        """Redefine optionxform so option names are case sensitive"""
50        return option
51
52    def getlist(self, section, option):
53        """Returns value of option as a list using whitespace to
54        split entries"""
55        value = self.get(section, option)
56        if value:
57            return value.split()
58        else:
59            return None
60
61    def get_matching_rules(self, repo):
62        """Return list of unique rules names that apply to a given repo"""
63        rules = {}
64        for option in self.options('repositories'):
65            if fnmatch.fnmatch(repo, option):
66                for rule in self.getlist('repositories', option):
67                    rules[rule] = True
68        return rules.keys()
69
70    def get_rule_section_name(self, rule):
71        """Given a rule name provide the section name it is defined in."""
72        return 'rule:%s' % (rule)
73
74class Commands:
75    """Class to handle logic of running commands"""
76    def __init__(self, config):
77        self.config = config
78
79    def svnlook_changed(self, repo, txn):
80        """Provide list of files changed in txn of repo"""
81        svnlook = self.config.get('DEFAULT', 'svnlook')
82        cmd = "'%s' changed -t '%s' '%s'" % (svnlook, txn, repo)
83        p = subprocess.Popen(cmd, shell=True,
84                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
85
86        changed = []
87        while True:
88            line = p.stdout.readline()
89            if not line:
90                break
91            line = line.strip()
92            text_mod = line[0:1]
93            # Only if the contents of the file changed (by addition or update)
94            # directories always end in / in the svnlook changed output
95            if line[-1:] != b"/" and (text_mod == b"A" or text_mod == b"U"):
96                changed_path = line[4:]
97                if not isinstance(changed_path, str):
98                    # svnlook always uses UTF-8 for internal path
99                    changed_path = changed_path.decode('utf-8')
100                changed.append(changed_path)
101
102        # wait on the command to finish so we can get the
103        # returncode/stderr output
104        data = p.communicate()
105        if p.returncode != 0:
106            err_mesg = data[1]
107            if sys.stderr.encoding:
108                err_mesg =err_mesg.decode(sys.stderr.encoding,
109                                          'backslashreplace')
110            sys.stderr.write(err_mesg)
111            sys.exit(2)
112
113        return changed
114
115    def user_command(self, section, repo, txn, fn):
116        """ Run the command defined for a given section.
117        Replaces $REPO, $TXN and $FILE with the repo, txn and fn arguments
118        in the defined command.
119
120        Returns a tuple of the exit code and the stderr output of the command"""
121        cmd = self.config.get(section, 'command')
122        cmd_env = os.environ.copy()
123        cmd_env['REPO'] = repo
124        cmd_env['TXN'] = txn
125        cmd_env['FILE'] = fn
126        p = subprocess.Popen(cmd, shell=True, env=cmd_env, stderr=subprocess.PIPE)
127        data = p.communicate()
128        err_mesg = data[1]
129        if sys.stderr.encoding:
130            err_mesg = err_mesg.decode(sys.stderr.encoding,
131                                       'backslashreplace')
132        return (p.returncode, err_mesg)
133
134def main(repo, txn):
135    exitcode = 0
136    config = Config()
137    config.read(os.path.join(repo, 'conf', 'validate-files.conf'))
138    commands = Commands(config)
139
140    rules = config.get_matching_rules(repo)
141
142    # no matching rules so nothing to do
143    if len(rules) == 0:
144        sys.exit(0)
145
146    changed = commands.svnlook_changed(repo, txn)
147    # this shouldn't ever happen
148    if len(changed) == 0:
149        sys.exit(0)
150
151    for rule in rules:
152        section = config.get_rule_section_name(rule)
153        pattern = config.get(section, 'pattern')
154
155        # skip leading slashes if present in the pattern
156        if pattern[0] == '/': pattern = pattern[1:]
157
158        for fn in fnmatch.filter(changed, pattern):
159            (returncode, err_mesg) = commands.user_command(section, repo,
160                                                           txn, fn)
161            if returncode != 0:
162                sys.stderr.write(
163                    "\nError validating file '%s' with rule '%s' " \
164                    "(exit code %d):\n" % (fn, rule, returncode))
165                sys.stderr.write(err_mesg)
166                exitcode = 1
167
168    return exitcode
169
170if __name__ == "__main__":
171    if len(sys.argv) != 3:
172        sys.stderr.write("invalid args\n")
173        sys.exit(0)
174
175    try:
176        sys.exit(main(sys.argv[1], sys.argv[2]))
177    except configparser.Error as e:
178        sys.stderr.write("Error with the validate-files.conf: %s\n" % e)
179        sys.exit(2)
180