#!/usr/bin/python -tt """Check permissions of a tree of git repositories, optionally fixing any problems found. """ import os import re import sys import optparse from stat import * from subprocess import call, PIPE, Popen ALL_CHECKS = ['bare', 'shared', 'mail-hook', 'fedmsg-hook', 'perms', 'post-update-hook'] DEFAULT_CHECKS = ['bare', 'shared', 'perms', 'post-update-hook'] OBJECT_RE = re.compile('[0-9a-z]{40}') def error(msg): print >> sys.stderr, msg def is_object(path): """Check if a path is a git object.""" parts = path.split(os.path.sep) if 'objects' in parts and len(parts) > 2 and \ OBJECT_RE.match(''.join(path.split(os.path.sep)[-2:])): return True return False def is_bare_repo(gitdir): """Check if a git repository is bare.""" cmd = ['git', '--git-dir', gitdir, 'config', '--bool', 'core.bare'] p = Popen(cmd, stdout=PIPE, stderr=PIPE) bare, error = p.communicate() if bare.rstrip() != 'true' or p.returncode: return False return True def is_shared_repo(gitdir): """Check if a git repository is shared.""" cmd = ['git', '--git-dir', gitdir, 'config', 'core.sharedRepository'] p = Popen(cmd, stdout=PIPE, stderr=PIPE) shared, error = p.communicate() sharedmodes = ['1', 'group', 'true', '2', 'all', 'world', 'everybody'] if shared.rstrip() not in sharedmodes or p.returncode: return False return True def uses_version1_mail_hook(gitdir): """Check if a git repository uses the old fedora-git-commit-mail-hook.""" hook = os.path.join(gitdir, 'hooks/update') oldpath = '/usr/bin/fedora-git-commit-mail-hook' return os.path.realpath(hook) == oldpath def uses_version2_mail_hook(gitdir): """Check if a git repository uses the pre-fedmsg mail-hook setup.""" hook = os.path.join(gitdir, 'hooks/post-receive') oldpath = '/usr/share/git-core/mail-hooks/gnome-post-receive-email' return os.path.realpath(hook) == oldpath def check_post_update_hook(gitdir, fix=False): """Check if a repo's post-update hook is setup correctly.""" hook = os.path.join(gitdir, 'hooks/post-update') realpath = os.path.realpath(hook) goodpath = '/usr/share/git-core/templates/hooks/post-update.sample' badpath = '/usr/bin/git-update-server-info' if realpath == goodpath: return True errmsg = '' if realpath == badpath: errmsg = '%s: symlinked to %s' % (hook, badpath) elif not os.path.exists(hook): errmsg = '%s: does not exist' % hook elif not os.access(hook, os.X_OK): errmsg = '%s: is not executable' % hook elif not os.path.islink(hook): errmsg = '%s: not a symlink' % hook else: errmsg = '%s: symlinked to %s' % (hook, realpath) error(errmsg) if not fix: return False if not os.path.exists(goodpath): error('%s: post-update hook (%s) does not exist.' % (gitdir, goodpath)) return False if os.path.exists(hook): try: os.rename(hook, '%s~' % hook) except (IOError, OSError), err: error('%s: Error renaming %s: %s' % (gitdir, hook, err.strerror)) return False try: os.symlink(goodpath, hook) except (IOError, OSError), err: error('%s: Error creating %s symlink: %s' % (gitdir, hook, err.strerror)) return False return True def set_bare_repo(gitdir): """Set core.bare for a git repository.""" cmd = ['git', '--git-dir', gitdir, 'config', '--bool', 'core.bare', 'true'] ret = call(cmd) if ret: return False return True def set_shared_repo(gitdir, value='group'): """Set core.sharedRepository for a git repository.""" mode_re = re.compile('06[0-7]{2}') if value in [0, 'false', 'umask']: value = 'umask' elif value in [1, 'true', 'group']: value = 'group' elif value in [2, 'all', 'world', 'everybody']: value = 'all' elif mode_re.match(value): pass else: raise SystemExit('Bogus core.sharedRepository value "%s"' % value) cmd = ['git', '--git-dir', gitdir, 'config', 'core.sharedRepository', value] ret = call(cmd) if ret: return False return True def set_post_receive_hook_version2(gitdir): """Configure a git repository to use the gnome mail hook without fedmsg.""" # Get recipients from the commit-list file. commit_list = os.path.join(gitdir, 'commit-list') if not os.path.exists(commit_list): error('%s: No commit-list file found' % gitdir) return False try: addrs = open(commit_list).read().strip() addrs = ', '.join(addrs.split()) except: error('%s: Unable to read commit-list file' % gitdir) return False # Set hooks.mailinglist if '@' not in addrs: addrs = '%s@lists.fedorahosted.org' cmd = ['git', '--git-dir', gitdir, 'config', 'hooks.mailinglist', addrs] p = Popen(cmd, stdout=PIPE, stderr=PIPE) stdout, stderr = p.communicate() if p.returncode: error('%s: Error setting hooks.mailinglist: %s' % (gitdir, stderr)) return False # Set hooks.maildomain cmd = ['git', '--git-dir', gitdir, 'config', 'hooks.maildomain', 'fedoraproject.org'] p = Popen(cmd, stdout=PIPE, stderr=PIPE) stdout, stderr = p.communicate() if p.returncode: error('%s: Error setting hooks.maildomain: %s' % (gitdir, stderr)) return False # Symlink mail notification script to post-receive hook script = '/usr/share/git-core/mail-hooks/gnome-post-receive-email' if not os.path.exists(script): error('%s: Mail hook (%s) does not exist.' % (gitdir, script)) return False hook = os.path.join(gitdir, 'hooks', 'post-receive') if os.path.exists(hook): try: os.remove(hook) except Exception, e: errstr = hasattr(e, 'strerror') and e.strerror or e error('%s: Error removing %s: %s' % (gitdir, hook, errstr)) return False try: os.symlink(script, hook) except Exception, e: errstr = hasattr(e, 'strerror') and e.strerror or e error('%s: Error creating %s symlink: %s' % (gitdir, hook, errstr)) return False # Clean up commit-list file and old update hook link try: os.rename(commit_list, '%s~' % commit_list) except (IOError, OSError), err: error('%s: Unable to backup commit-list: %s' % (gitdir, err.strerror)) return False try: oldhook = os.path.join(gitdir, 'hooks/update') os.remove(oldhook) except (IOError, OSError), err: error('%s: Unable to backup commit-list: %s' % (gitdir, err.strerror)) return False # We ran the gauntlet. return True def set_post_receive_hook_version3(gitdir): """Configure a git repository to use the fedmsg+gnome-mail hooks.""" if not uses_version2_mail_hook(gitdir): error('%s: Not yet on version2 mail hook; do --fix=mail-hook' % gitdir) return False # Check that the destination is 'okay' dest_prefix = os.path.join(gitdir, 'hooks', 'post-receive-chained.d') if not os.path.exists(dest_prefix): os.mkdir(dest_prefix) if not os.path.isdir(dest_prefix): error('%s: %s is not a directory.' % (gitdir, dest_prefix)) return False # Symlink mail notification and fedmsg scripts to post-receive hook scripts = { '/usr/share/git-core/mail-hooks/gnome-post-receive-email': os.path.join(dest_prefix, 'post-receive-email'), '/usr/share/git-core/post-receive-fedmsg': os.path.join(dest_prefix, 'post-receive-fedmsg'), # This one kicks off all the others. '/usr/share/git-core/post-receive-chained': os.path.join(gitdir, 'hooks', 'post-receive'), } for script, hook in scripts.items(): if not os.path.exists(script): error('%s: Hook (%s) does not exist.' % (gitdir, script)) return False if os.path.exists(hook): try: os.remove(hook) except Exception, e: errstr = hasattr(e, 'strerror') and e.strerror or e error('%s: Error removing %s: %s' % (gitdir, hook, errstr)) return False try: os.symlink(script, hook) except Exception, e: errstr = hasattr(e, 'strerror') and e.strerror or e error('%s: Error creating %s symlink: %s' % (gitdir, hook, errstr)) return False # We ran the gauntlet. return True def list_checks(): print 'Available checks: %s' % ', '.join(ALL_CHECKS) print 'Default checks: %s' % ', '.join(DEFAULT_CHECKS) def check_git_perms(path, fix=False): """Check if permissions on a git repo are correct. If fix is true, problems found are corrected. """ object_mode = S_IRUSR | S_IRGRP | S_IROTH oldmode = mode = S_IMODE(os.lstat(path)[ST_MODE]) errors = [] if os.path.isdir(path): newmode = mode | S_ISGID if mode != newmode: msg = 'Not SETGID (should be "%s")' % oct(newmode) errors.append(msg) mode = newmode elif is_object(path) and mode ^ object_mode: msg = 'Wrong object mode "%s" (should be "%s")' % ( oct(mode), oct(object_mode)) errors.append(msg) mode = object_mode if mode & S_IWUSR and not is_object(path): newmode = mode | S_IWGRP exempt = \ any(map(path.endswith, ['commit-list', 'gl-conf'])) or \ any(map(path.__contains__, ['/hooks/'])) if mode != newmode and not exempt: msg = 'Not group writable (should be "%s")' % oct(newmode) errors.append(msg) mode = newmode if mode != oldmode and not os.path.islink(path): errmsg = '%s:' % path errmsg += ', '.join(['%s' % e for e in errors]) error(errmsg) if not fix: return False try: os.chmod(path, mode) return True except Exception, e: errstr = hasattr(e, 'strerror') and e.strerror or e mode = oct(mode) error('%s: Error setting "%s" mode on %s: %s' % (gitdir, mode, path, errstr)) return False return True def main(): usage = '%prog [options] [gitroot]' parser = optparse.OptionParser(usage=usage) parser.add_option('-f', '--fix', action='store_true', default=False, help='Correct any problems [%default]') parser.add_option('-l', '--list-checks', action='store_true', help='List default checks') parser.add_option('-c', '--check', dest='checks', action='append', default=[], metavar='check', help='Add a check, may be used multiple times') parser.add_option('-s', '--skip', action='append', default=[], metavar='check', help='Skip a check, may be used multiple times') parser.add_option('-r', '--repo', default=None, help="Check only a certain repo, not all of them.") opts, args = parser.parse_args() # Check options if opts.list_checks: list_checks() raise SystemExit if opts.checks: checks = set(opts.checks) bad_check_opts = checks.difference(set(ALL_CHECKS)) if bad_check_opts: msg = 'Bad check(s): %s' % ', '.join(sorted(bad_check_opts)) msg += '\nAvailable checks: %s' % ', '.join(ALL_CHECKS) raise SystemExit(msg) else: bad_skip_opts = set(opts.skip).difference(set(ALL_CHECKS)) if bad_skip_opts: msg = 'Bad skip option(s): %s' % ', '.join(sorted(bad_skip_opts)) msg += '\nAvailable checks: %s' % ', '.join(ALL_CHECKS) raise SystemExit(msg) checks = set() for check in DEFAULT_CHECKS: if check not in opts.skip: checks.add(check) # Check args if len(args) > 1: raise SystemExit(parser.get_usage().strip()) gitroot = args and args[0] or '/git' if not os.path.isdir(gitroot): raise SystemExit('%s does not exist or is not a directory' % gitroot) if opts.repo: gitdirs = ['/'.join([gitroot, opts.repo])] else: gitdirs = [] for path, dirs, files in os.walk(gitroot): if path in gitdirs: continue if 'description' in os.listdir(path): gitdirs.append(path) problems = [] for gitdir in sorted(gitdirs): if 'bare' in checks and not is_bare_repo(gitdir): error('%s: core.bare not true' % gitdir) if not opts.fix or not set_bare_repo(gitdir): problems.append(gitdir) if 'shared' in checks and not is_shared_repo(gitdir): error('%s: core.sharedRepository not set' % gitdir) if not opts.fix or not set_shared_repo(gitdir): problems.append(gitdir) if 'mail-hook' in checks and uses_version1_mail_hook(gitdir): error('%s: uses old mail hook' % gitdir) if not opts.fix or not set_post_receive_hook_version2(gitdir): problems.append(gitdir) if 'fedmsg-hook' in checks and (uses_version1_mail_hook(gitdir) or uses_version2_mail_hook(gitdir)): error('%s: uses the gnome mail hook or older' % gitdir) if not opts.fix or not set_post_receive_hook_version3(gitdir): problems.append(gitdir) if 'post-update-hook' in checks and not check_post_update_hook(gitdir, opts.fix): problems.append(gitdir) if 'perms' in checks: paths = [] for path, dirs, files in os.walk(gitdir): for d in dirs: d = os.path.join(path, d) if d not in paths: paths.append(d) for f in files: f = os.path.join(path, f) if f not in paths: paths.append(f) for path in paths: if not check_git_perms(path, fix=opts.fix): problems.append(path) if problems: raise SystemExit('%d problems remain unfixed' % len(problems)) raise SystemExit() if __name__ == '__main__': try: main() except KeyboardInterrupt: raise SystemExit('\nExiting on user cancel (Ctrl-C)')