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