diff options
-rw-r--r-- | gitosis/access.py | 3 | ||||
-rw-r--r-- | gitosis/app.py | 41 | ||||
-rw-r--r-- | gitosis/configutil.py | 56 | ||||
-rw-r--r-- | gitosis/gitdaemon.py | 101 | ||||
-rw-r--r-- | gitosis/gitweb.py | 104 | ||||
-rw-r--r-- | gitosis/group.py | 25 | ||||
-rw-r--r-- | gitosis/init.py | 113 | ||||
-rw-r--r-- | gitosis/repository.py | 31 | ||||
-rw-r--r-- | gitosis/run_hook.py | 63 | ||||
-rw-r--r-- | gitosis/serve.py | 67 | ||||
-rw-r--r-- | gitosis/ssh.py | 66 | ||||
-rw-r--r-- | gitosis/sshkey.py | 203 | ||||
-rw-r--r-- | gitosis/test/test_gitweb.py | 20 | ||||
-rw-r--r-- | gitosis/test/test_init.py | 88 | ||||
-rw-r--r-- | gitosis/test/test_repository.py | 7 | ||||
-rw-r--r-- | gitosis/test/test_run_hook.py | 34 | ||||
-rw-r--r-- | gitosis/test/test_serve.py | 50 | ||||
-rw-r--r-- | gitosis/test/test_ssh.py | 31 | ||||
-rw-r--r-- | gitosis/test/test_sshkey.py | 99 | ||||
-rw-r--r-- | gitosis/test/test_util.py | 65 | ||||
-rw-r--r-- | gitosis/test/test_zzz_app.py | 108 | ||||
-rw-r--r-- | gitosis/util.py | 55 | ||||
-rw-r--r-- | pylintrc | 305 |
23 files changed, 1376 insertions, 359 deletions
diff --git a/gitosis/access.py b/gitosis/access.py index c95c842..869fec8 100644 --- a/gitosis/access.py +++ b/gitosis/access.py @@ -1,3 +1,6 @@ +""" +Gitosis access control functions +""" import os, logging from ConfigParser import NoSectionError, NoOptionError diff --git a/gitosis/app.py b/gitosis/app.py index fa9772b..b2c44be 100644 --- a/gitosis/app.py +++ b/gitosis/app.py @@ -1,10 +1,14 @@ +"""Common code for all callable Gitosis programs.""" import os import sys import logging import optparse import errno import ConfigParser +from gitosis import configutil +# C0103 - 'log' is a special name +# pylint: disable-msg=C0103 log = logging.getLogger('gitosis.app') class CannotReadConfigError(Exception): @@ -17,30 +21,41 @@ class ConfigFileDoesNotExistError(CannotReadConfigError): """Configuration does not exist""" class App(object): + """Common Gitosis Application runner.""" + # R0201 - the many of the methods in this class are intended to be + # overridden, hence they are not suited to be functions. + # W0613 - They also might ignore arguments here, where the descendant + # methods won't. + # pylint: disable-msg=R0201,W0613 + name = None - def run(class_): - app = class_() + def run(cls): + """Launch the app.""" + app = cls() return app.main() run = classmethod(run) def main(self): + """Main program routine.""" self.setup_basic_logging() parser = self.create_parser() (options, args) = parser.parse_args() cfg = self.create_config(options) try: self.read_config(options, cfg) - except CannotReadConfigError, e: - log.error(str(e)) + except CannotReadConfigError, ex: + log.error(str(ex)) sys.exit(1) self.setup_logging(cfg) self.handle_args(parser, cfg, options, args) def setup_basic_logging(self): + """Set up the initial logging.""" logging.basicConfig() def create_parser(self): + """Handle commandline option parsing.""" parser = optparse.OptionParser() parser.set_defaults( config=os.path.expanduser('~/.gitosis.conf'), @@ -53,25 +68,28 @@ class App(object): return parser def create_config(self, options): - cfg = ConfigParser.RawConfigParser() + """Handle config file parsing.""" + cfg = configutil.GitosisRawConfigParser() return cfg def read_config(self, options, cfg): + """Read the configuration file into the config parser.""" try: conffile = file(options.config) - except (IOError, OSError), e: - if e.errno == errno.ENOENT: + except (IOError, OSError), ex: + if ex.errno == errno.ENOENT: # special case this because gitosis-init wants to # ignore this particular error case - raise ConfigFileDoesNotExistError(str(e)) + raise ConfigFileDoesNotExistError(str(ex)) else: - raise CannotReadConfigError(str(e)) + raise CannotReadConfigError(str(ex)) #pragma: no cover try: cfg.readfp(conffile) finally: conffile.close() def setup_logging(self, cfg): + """Set up the full logging, using the configuration.""" try: loglevel = cfg.get('gitosis', 'loglevel') except (ConfigParser.NoSectionError, @@ -79,6 +97,8 @@ class App(object): pass else: try: + # logging really should declare the symbolics + # pylint: disable-msg=W0212 symbolic = logging._levelNames[loglevel] except KeyError: log.warning( @@ -88,6 +108,7 @@ class App(object): else: logging.root.setLevel(symbolic) - def handle_args(self, parser, cfg, options, args): + def handle_args(self, parser, cfg, options, args): #pragma: no cover + """Abstract method for the non-option argument handling.""" if args: parser.error('not expecting arguments') diff --git a/gitosis/configutil.py b/gitosis/configutil.py new file mode 100644 index 0000000..3215b85 --- /dev/null +++ b/gitosis/configutil.py @@ -0,0 +1,56 @@ +""" +Useful wrapper functions to access ConfigParser structures. +""" +from ConfigParser import NoSectionError, NoOptionError, RawConfigParser +from UserDict import IterableUserDict + +def getboolean_default(config, section, option, default_value): + """ + Return the given section.variable, or return the default if no specific + value is set. + """ + try: + value = config.getboolean(section, option) + except (NoSectionError, NoOptionError): + value = default_value + return value +def get_default(config, section, option, default_value): + """ + Return the given section.variable, or return the default if no specific + value is set. + """ + try: + value = config.get(section, option) + except (NoSectionError, NoOptionError): + value = default_value + return value + +class GitosisConfigDict(IterableUserDict): + def keys(self): + return list(self.__iter__()) + def __iter__(self): + saw = set() + if 'gitosis' in self.data: + saw.add('gitosis') + yield 'gitosis' + sorted_keys = self.data.keys() + sorted_keys.sort() + for _ in sorted_keys: + if _.startswith('group '): + saw.add(_) + yield _ + for _ in sorted_keys: + if _.startswith('repo '): + saw.add(_) + yield _ + for _ in sorted_keys: + if _ not in saw: + saw.add(_) + yield _ + + +class GitosisRawConfigParser(RawConfigParser): + def __init__(self, defaults=None): + RawConfigParser.__init__(self, defaults) + self._sections = GitosisConfigDict(self._sections) + diff --git a/gitosis/gitdaemon.py b/gitosis/gitdaemon.py index 78ca9ea..1e43047 100644 --- a/gitosis/gitdaemon.py +++ b/gitosis/gitdaemon.py @@ -1,32 +1,41 @@ +""" +Gitosis git-daemon functionality. + +Handles the ``git-daemon-export-ok`` marker files for all repositories managed +by Gitosis. +""" import errno import logging import os -from ConfigParser import NoSectionError, NoOptionError - +# C0103 - 'log' is a special name +# pylint: disable-msg=C0103 log = logging.getLogger('gitosis.gitdaemon') from gitosis import util +from gitosis.configutil import getboolean_default def export_ok_path(repopath): - p = os.path.join(repopath, 'git-daemon-export-ok') - return p + """ + Return the path the ``git-daemon-export-ok`` marker for a given repository. + """ + path = os.path.join(repopath, 'git-daemon-export-ok') + return path def allow_export(repopath): - p = export_ok_path(repopath) - file(p, 'a').close() + """Create the ``git-daemon-export-ok`` marker for a given repository.""" + path = export_ok_path(repopath) + file(path, 'a').close() def deny_export(repopath): - p = export_ok_path(repopath) - try: - os.unlink(p) - except OSError, e: - if e.errno == errno.ENOENT: - pass - else: - raise + """Remove the ``git-daemon-export-ok`` marker for a given repository.""" + path = export_ok_path(repopath) + util.unlink(path) def _extract_reldir(topdir, dirpath): + """ + Find the relative directory given a base directory & a child directory. + """ if topdir == dirpath: return '.' prefix = topdir + '/' @@ -34,25 +43,53 @@ def _extract_reldir(topdir, dirpath): reldir = dirpath[len(prefix):] return reldir -def set_export_ok(config): - repositories = util.getRepositoryDir(config) - - try: - global_enable = config.getboolean('gitosis', 'daemon') - except (NoSectionError, NoOptionError): - global_enable = False +def _is_global_repo_export_ok(config): + """ + Does the global Gitosis configuration allow daemon exporting? + """ + global_enable = getboolean_default(config, 'gitosis', 'daemon', False) log.debug( 'Global default is %r', {True: 'allow', False: 'deny'}.get(global_enable), ) + return global_enable - def _error(e): - if e.errno == errno.ENOENT: +def _is_repo_export_ok(global_enable, config, reponame): + """ + Does the Gitosis configuration for the named reposistory allow daemon + exporting? + """ + section = 'repo %s' % reponame + return getboolean_default(config, section, 'daemon', global_enable) + +def _set_export_ok_single(enable, name, dirpath, repo): + """ + Manage the ``git-daemon-export-ok`` marker for a single repository. + """ + repopath = os.path.join(dirpath, repo) + if enable: + log.debug('Allow %r', name) + allow_export(repopath) + else: + log.debug('Deny %r', name) + deny_export(repopath) + +def set_export_ok(config): + """ + Walk all repositories owned by Gitosis, and manage the + ``git-daemon-export-ok`` markers. + """ + repositories = util.getRepositoryDir(config) + global_enable = _is_global_repo_export_ok(config) + + def _error(ex): #pragma: no cover + """Ignore non-existant items.""" + if ex.errno == errno.ENOENT: pass else: - raise e + raise ex - for (dirpath, dirnames, filenames) \ + for (dirpath, dirnames, _) \ in os.walk(repositories, onerror=_error): # oh how many times i have wished for os.walk to report # topdir and reldir separately, instead of dirpath @@ -77,14 +114,6 @@ def set_export_ok(config): if reldir != '.': name = os.path.join(reldir, name) assert ext == '.git' - try: - enable = config.getboolean('repo %s' % name, 'daemon') - except (NoSectionError, NoOptionError): - enable = global_enable - - if enable: - log.debug('Allow %r', name) - allow_export(os.path.join(dirpath, repo)) - else: - log.debug('Deny %r', name) - deny_export(os.path.join(dirpath, repo)) + + enable = _is_repo_export_ok(global_enable, config, name) + _set_export_ok_single(enable, name, dirpath, repo) diff --git a/gitosis/gitweb.py b/gitosis/gitweb.py index b4b538b..25076b1 100644 --- a/gitosis/gitweb.py +++ b/gitosis/gitweb.py @@ -5,18 +5,18 @@ To plug this into ``gitweb``, you have two choices. - The global way, edit ``/etc/gitweb.conf`` to say:: - $projects_list = "/path/to/your/projects.list"; + $projects_list = "/path/to/your/projects.list"; Note that there can be only one such use of gitweb. - The local way, create a new config file:: - do "/etc/gitweb.conf" if -e "/etc/gitweb.conf"; - $projects_list = "/path/to/your/projects.list"; + do "/etc/gitweb.conf" if -e "/etc/gitweb.conf"; + $projects_list = "/path/to/your/projects.list"; # see ``repositories`` in the ``gitosis`` section # of ``~/.gitosis.conf``; usually ``~/repositories`` # but you need to expand the tilde here - $projectroot = "/path/to/your/repositories"; + $projectroot = "/path/to/your/repositories"; Then in your web server, set environment variable ``GITWEB_CONFIG`` to point to this file. @@ -30,12 +30,14 @@ import os, urllib, logging from ConfigParser import NoSectionError, NoOptionError from gitosis import util +from gitosis.configutil import getboolean_default -def _escape_filename(s): - s = s.replace('\\', '\\\\') - s = s.replace('$', '\\$') - s = s.replace('"', '\\"') - return s +def _escape_filename(i): + """Try to make the filename safer.""" + i = i.replace('\\', '\\\\') + i = i.replace('$', '\\$') + i = i.replace('"', '\\"') + return i def generate_project_list_fp(config, fp): """ @@ -51,37 +53,21 @@ def generate_project_list_fp(config, fp): repositories = util.getRepositoryDir(config) - try: - global_enable = config.getboolean('gitosis', 'gitweb') - except (NoSectionError, NoOptionError): - global_enable = False + global_enable = getboolean_default(config, 'gitosis', 'gitweb', False) for section in config.sections(): - l = section.split(None, 1) - type_ = l.pop(0) - if type_ != 'repo': - continue - if not l: + sectiontitle = section.split(None, 1) + if not sectiontitle or sectiontitle[0] != 'repo': continue - try: - enable = config.getboolean(section, 'gitweb') - except (NoSectionError, NoOptionError): - enable = global_enable + enable = getboolean_default(config, section, 'gitweb', global_enable) if not enable: continue - name, = l + name = sectiontitle[1] - if not os.path.exists(os.path.join(repositories, name)): - namedotgit = '%s.git' % name - if os.path.exists(os.path.join(repositories, namedotgit)): - name = namedotgit - else: - log.warning( - 'Cannot find %(name)r in %(repositories)r' - % dict(name=name, repositories=repositories)) + name = _repository_exists(log, repositories, name, name) response = [name] try: @@ -91,8 +77,25 @@ def generate_project_list_fp(config, fp): else: response.append(owner) - line = ' '.join([urllib.quote_plus(s) for s in response]) - print >>fp, line + line = ' '.join([urllib.quote_plus(_) for _ in response]) + print >> fp, line + + +def _repository_exists(log, repositories, name, default_value): + """ + Check if the repository exists by the common name, or with a .git suffix, + and return the relative name. + """ + if not os.path.exists(os.path.join(repositories, name)): + namedotgit = '%s.git' % name + if os.path.exists(os.path.join(repositories, namedotgit)): + name = namedotgit + else: + log.warning( + 'Cannot find %(name)r in %(repositories)r' + % dict(name=name, repositories=repositories)) + return default_value + return name def generate_project_list(config, path): """ @@ -106,11 +109,11 @@ def generate_project_list(config, path): """ tmp = '%s.%d.tmp' % (path, os.getpid()) - f = file(tmp, 'w') + fp = file(tmp, 'w') try: - generate_project_list_fp(config=config, fp=f) + generate_project_list_fp(config=config, fp=fp) finally: - f.close() + fp.close() os.rename(tmp, path) @@ -124,11 +127,8 @@ def set_descriptions(config): repositories = util.getRepositoryDir(config) for section in config.sections(): - l = section.split(None, 1) - type_ = l.pop(0) - if type_ != 'repo': - continue - if not l: + sectiontitle = section.split(None, 1) + if not sectiontitle or sectiontitle[0] != 'repo': continue try: @@ -136,20 +136,14 @@ def set_descriptions(config): except (NoSectionError, NoOptionError): continue - if not description: + if not description: #pragma: no cover continue - name, = l + name = sectiontitle[1] - if not os.path.exists(os.path.join(repositories, name)): - namedotgit = '%s.git' % name - if os.path.exists(os.path.join(repositories, namedotgit)): - name = namedotgit - else: - log.warning( - 'Cannot find %(name)r in %(repositories)r' - % dict(name=name, repositories=repositories)) - continue + name = _repository_exists(log, repositories, name, False) + if not name: + continue path = os.path.join( repositories, @@ -157,9 +151,9 @@ def set_descriptions(config): 'description', ) tmp = '%s.%d.tmp' % (path, os.getpid()) - f = file(tmp, 'w') + fp = file(tmp, 'w') try: - print >>f, description + print >> fp, description finally: - f.close() + fp.close() os.rename(tmp, path) diff --git a/gitosis/group.py b/gitosis/group.py index a18a731..5190aef 100644 --- a/gitosis/group.py +++ b/gitosis/group.py @@ -1,14 +1,27 @@ +""" +Gitosis functions to find what groups a given user belongs to. +""" import logging from ConfigParser import NoSectionError, NoOptionError + +_GROUP_PREFIX = 'group ' def _getMembership(config, user, seen): + """ + Internal implementation of getMembership. + Generate groups ``user`` is member of, according to ``config``. + Groups already seen are tracked by ``seen``. + + :type config: RawConfigParser + :type user: str + :type seen: Set + """ log = logging.getLogger('gitosis.group.getMembership') for section in config.sections(): - GROUP_PREFIX = 'group ' - if not section.startswith(GROUP_PREFIX): + if not section.startswith(_GROUP_PREFIX): continue - group = section[len(GROUP_PREFIX):] + group = section[len(_GROUP_PREFIX):] if group in seen: continue @@ -35,7 +48,10 @@ def _getMembership(config, user, seen): config, '@%s' % group, seen, ): yield member_of - + for member_of in _getMembership( + config, '@all', seen, + ): + yield member_of def getMembership(config, user): """ @@ -43,7 +59,6 @@ def getMembership(config, user): :type config: RawConfigParser :type user: str - :param _seen: internal use only """ seen = set() diff --git a/gitosis/init.py b/gitosis/init.py index 87ad9a7..ceeaba6 100644 --- a/gitosis/init.py +++ b/gitosis/init.py @@ -2,43 +2,35 @@ Initialize a user account for use with gitosis. """ -import errno import logging import os import sys from pkg_resources import resource_filename from cStringIO import StringIO -from ConfigParser import RawConfigParser +from gitosis import configutil from gitosis import repository from gitosis import run_hook -from gitosis import ssh +from gitosis import sshkey from gitosis import util from gitosis import app +# C0103 - 'log' is a special name +# pylint: disable-msg=C0103 log = logging.getLogger('gitosis.init') -def read_ssh_pubkey(fp=None): - if fp is None: +def read_ssh_pubkey(filename=None): #pragma: no cover + """Read an SSH public key from stdin or file.""" + if filename is None: fp = sys.stdin + else: + fp = file(filename) line = fp.readline() return line -class InsecureSSHKeyUsername(Exception): - """Username contains not allowed characters""" - - def __str__(self): - return '%s: %s' % (self.__doc__, ': '.join(self.args)) - -def ssh_extract_user(pubkey): - _, user = pubkey.rsplit(None, 1) - if ssh.isSafeUsername(user): - return user - else: - raise InsecureSSHKeyUsername(repr(user)) - def initial_commit(git_dir, cfg, pubkey, user): + """Import the initial files into the gitosis-admin repository.""" repository.fast_import( git_dir=git_dir, commit_msg='Automatic creation of gitosis repository.', @@ -49,27 +41,22 @@ def initial_commit(git_dir, cfg, pubkey, user): ], ) -def symlink_config(git_dir): +def symlink_config(git_dir): #pragma: no cover + """ + Place a symlink for the gitosis.conf file in the homedir of the gitosis + user, to make possible to find initially. + """ dst = os.path.expanduser('~/.gitosis.conf') tmp = '%s.%d.tmp' % (dst, os.getpid()) - try: - os.unlink(tmp) - except OSError, e: - if e.errno == errno.ENOENT: - pass - else: - raise + util.unlink(tmp) os.symlink( os.path.join(git_dir, 'gitosis.conf'), tmp, ) os.rename(tmp, dst) -def init_admin_repository( - git_dir, - pubkey, - user, - ): +def init_admin_repository(git_dir, pubkey, user, config): + """Create the initial gitosis-admin reposistory.""" repository.init( path=git_dir, template=resource_filename('gitosis.templates', 'admin') @@ -77,18 +64,26 @@ def init_admin_repository( repository.init( path=git_dir, ) + # Check that the config meets the min requirements + if not config.has_section('gitosis'): + config.add_section('gitosis') + if not config.has_section('group gitosis-admin'): + config.add_section('group gitosis-admin') + if not config.has_option('group gitosis-admin', 'writable'): + config.set('group gitosis-admin', 'writable', 'gitosis-admin') + + # Make sure the admin user is in the admin list, else they will lock themselves out! + adminlist = configutil.get_default(config, 'group gitosis-admin', 'members',' ').split() + if user not in adminlist: + adminlist.append(user) + config.set('group gitosis-admin', 'members', ' '.join(adminlist)) + if not repository.has_initial_commit(git_dir): log.info('Making initial commit...') # ConfigParser does not guarantee order, so jump through hoops # to make sure [gitosis] is first cfg_file = StringIO() - print >>cfg_file, '[gitosis]' - print >>cfg_file - cfg = RawConfigParser() - cfg.add_section('group gitosis-admin') - cfg.set('group gitosis-admin', 'members', user) - cfg.set('group gitosis-admin', 'writable', 'gitosis-admin') - cfg.write(cfg_file) + config.write(cfg_file) initial_commit( git_dir=git_dir, cfg=cfg_file.getvalue(), @@ -97,28 +92,55 @@ def init_admin_repository( ) class Main(app.App): + """gitosis-init program.""" + # W0613 - They also might ignore arguments here, where the descendant + # methods won't. + # pylint: disable-msg=W0613 + def create_parser(self): + """Declare the input for this program.""" parser = super(Main, self).create_parser() parser.set_usage('%prog [OPTS]') parser.set_description( - 'Initialize a user account for use with gitosis') + 'Initialize a user account for use with gitosis' + ) + parser.set_defaults( + adminkey=None, + adminname=None, + ) + parser.add_option('--adminkey', + metavar='FILE', + help='Admin SSH public key FILE location', + ) + parser.add_option('--adminname', + metavar='STRING', + help='Name for administrator public key file', + ) return parser - def read_config(self, *a, **kw): - # ignore errors that result from non-existent config file + def read_config(self, options, cfg): + """Ignore errors that result from non-existent config file.""" + # Pylint gets it wrong. + # pylint: disable-msg=W0704 try: - super(Main, self).read_config(*a, **kw) + super(Main, self).read_config(options, cfg) except app.ConfigFileDoesNotExistError: pass - def handle_args(self, parser, cfg, options, args): + def handle_args(self, parser, cfg, options, args): #pragma: no cover + """Parse the input for this program.""" super(Main, self).handle_args(parser, cfg, options, args) os.umask(0022) log.info('Reading SSH public key...') - pubkey = read_ssh_pubkey() - user = ssh_extract_user(pubkey) + pubkey = read_ssh_pubkey(options.adminkey) + if options.adminname is None: + _ = sshkey.get_ssh_pubkey(pubkey) + user = _.username + else: + user = options.adminname + user = user.strip() if user is None: log.error('Cannot parse user from SSH public key.') sys.exit(1) @@ -134,6 +156,7 @@ class Main(app.App): git_dir=admin_repository, pubkey=pubkey, user=user, + config=cfg, ) log.info('Running post-update hook...') util.mkdir(os.path.expanduser('~/.ssh'), 0700) diff --git a/gitosis/repository.py b/gitosis/repository.py index 092e41d..272c3cb 100644 --- a/gitosis/repository.py +++ b/gitosis/repository.py @@ -1,4 +1,6 @@ -import errno +""" +Gitosis functions for dealing with Git repositories. +""" import os import re import subprocess @@ -19,6 +21,7 @@ def init( path, template=None, _git=None, + mode=0750, ): """ Create a git repository at C{path} (if missing). @@ -32,11 +35,15 @@ def init( @param template: Template directory, to pass to C{git init}. @type template: str + + @param mode: Permissions for the new reposistory + + @type mode: int """ if _git is None: _git = 'git' - util.mkdir(path, 0750) + util.mkdir(path, mode) args = [ _git, '--git-dir=.', @@ -50,7 +57,7 @@ def init( stdout=sys.stderr, close_fds=True, ) - if returncode != 0: + if returncode != 0: #pragma: no cover raise GitInitError('exit status %d' % returncode) @@ -113,7 +120,7 @@ from %(parent)s child.stdin.write('M 100644 :%d %s\n' % (index+1, path)) child.stdin.close() returncode = child.wait() - if returncode != 0: + if returncode != 0: #pragma: no cover raise GitFastImportError( 'git fast-import failed', 'exit status %d' % returncode) @@ -128,13 +135,8 @@ class GitCheckoutIndexError(GitExportError): """git checkout-index failed""" def export(git_dir, path): - try: - os.mkdir(path) - except OSError, e: - if e.errno == errno.EEXIST: - pass - else: - raise + """Export a Git repository to a given path.""" + util.mkdir(path) returncode = subprocess.call( args=[ 'git', @@ -144,7 +146,7 @@ def export(git_dir, path): ], close_fds=True, ) - if returncode != 0: + if returncode != 0: #pragma: no cover raise GitReadTreeError('exit status %d' % returncode) # jumping through hoops to be compatible with git versions # that don't have --work-tree= @@ -163,7 +165,7 @@ def export(git_dir, path): close_fds=True, env=env, ) - if returncode != 0: + if returncode != 0: #pragma: no cover raise GitCheckoutIndexError('exit status %d' % returncode) class GitHasInitialCommitError(GitError): @@ -173,6 +175,7 @@ class GitRevParseError(GitError): """rev-parse failed""" def has_initial_commit(git_dir): + """Check if a Git repo contains at least one commit linked by HEAD.""" child = subprocess.Popen( args=[ 'git', @@ -192,5 +195,5 @@ def has_initial_commit(git_dir): return False elif re.match('^[0-9a-f]{40}\n$', got): return True - else: + else: #pragma: no cover raise GitHasInitialCommitError('Unknown git HEAD: %r' % got) diff --git a/gitosis/run_hook.py b/gitosis/run_hook.py index e535e6a..ef12310 100644 --- a/gitosis/run_hook.py +++ b/gitosis/run_hook.py @@ -2,11 +2,9 @@ Perform gitosis actions for a git hook. """ -import errno import logging import os import sys -import shutil from gitosis import repository from gitosis import ssh @@ -15,15 +13,39 @@ from gitosis import gitdaemon from gitosis import app from gitosis import util -def post_update(cfg, git_dir): +def build_reposistory_data(config): + """ + Using the ``config`` data, perform all actions that affect files in the .git + repositories, such as the description, owner, and export marker. Also + update the projects.list file as needed to list relevant repositories. + + :type config: RawConfigParser + """ + gitweb.set_descriptions( + config=config, + ) + generated = util.getGeneratedFilesDir(config=config) + gitweb.generate_project_list( + config=config, + path=os.path.join(generated, 'projects.list'), + ) + gitdaemon.set_export_ok( + config=config, + ) + +def post_update(cfg, git_dir): #pragma: no cover + """ + post-update hook for the Gitosis admin directory. + + 1. Make an export of the admin repo to a clean directory. + 2. Move the gitosis.conf file to it's destination. + 3. Update the repository descriptions. + 4. Update the projects.list file. + 5. Update the repository export markers. + 6. Update the Gitosis SSH keys. + """ export = os.path.join(git_dir, 'gitosis-export') - try: - shutil.rmtree(export) - except OSError, e: - if e.errno == errno.ENOENT: - pass - else: - raise + util.rmtree(export) repository.export(git_dir=git_dir, path=export) os.rename( os.path.join(export, 'gitosis.conf'), @@ -31,17 +53,7 @@ def post_update(cfg, git_dir): ) # re-read config to get up-to-date settings cfg.read(os.path.join(export, '..', 'gitosis.conf')) - gitweb.set_descriptions( - config=cfg, - ) - generated = util.getGeneratedFilesDir(config=cfg) - gitweb.generate_project_list( - config=cfg, - path=os.path.join(generated, 'projects.list'), - ) - gitdaemon.set_export_ok( - config=cfg, - ) + build_reposistory_data(cfg) authorized_keys = util.getSSHAuthorizedKeysPath(config=cfg) ssh.writeAuthorizedKeys( path=authorized_keys, @@ -49,14 +61,21 @@ def post_update(cfg, git_dir): ) class Main(app.App): + """gitosis-run-hook program.""" + # W0613 - They also might ignore arguments here, where the descendant + # methods won't. + # pylint: disable-msg=W0613 + def create_parser(self): + """Declare the input for this program.""" parser = super(Main, self).create_parser() parser.set_usage('%prog [OPTS] HOOK') parser.set_description( 'Perform gitosis actions for a git hook') return parser - def handle_args(self, parser, cfg, options, args): + def handle_args(self, parser, cfg, options, args): #pragma: no cover + """Parse the input for this program.""" try: (hook,) = args except ValueError: diff --git a/gitosis/serve.py b/gitosis/serve.py index 867249e..2ba8a75 100644 --- a/gitosis/serve.py +++ b/gitosis/serve.py @@ -9,11 +9,11 @@ import logging import sys, os, re from gitosis import access +from gitosis import configutil from gitosis import repository -from gitosis import gitweb -from gitosis import gitdaemon from gitosis import app from gitosis import util +from gitosis import run_hook log = logging.getLogger('gitosis.serve') @@ -53,11 +53,11 @@ class WriteAccessDenied(AccessDenied): class ReadAccessDenied(AccessDenied): """Repository read access denied""" -def serve( - cfg, - user, - command, - ): +def serve(cfg, user, command): + """Check the git command for sanity, and then run the git command.""" + + log = logging.getLogger('gitosis.serve.serve') + if '\n' in command: raise CommandMayNotContainNewlineError() @@ -80,6 +80,23 @@ def serve( if (verb not in COMMANDS_WRITE and verb not in COMMANDS_READONLY): raise UnknownCommandError() + + log.debug('Got command %(cmd)r and args %(args)r' % dict( + cmd=verb, + args=args, + )) + + if args.startswith("'/") and args.endswith("'"): + args = args[1:-1] + repos = util.getRepositoryDir(cfg) + reposreal = os.path.realpath(repos) + if args.startswith(repos): + args = os.path.realpath(args)[len(repos)+1:] + elif args.startswith(reposreal): + args = os.path.realpath(args)[len(reposreal)+1:] + else: + args = args[1:] + args = "'%s'" % (args, ) match = ALLOW_RE.match(args) if match is None: @@ -137,23 +154,20 @@ def serve( # authorized to do that: create the repository on the fly # create leading directories - p = topdir + path = topdir + newdirmode = configutil.get_default(cfg, 'repo %s' % (relpath, ), 'dirmode', None) + if newdirmode is None: + newdirmode = configutil.get_default(cfg, 'gitosis', 'dirmode', '0750') + + # Convert string as octal to a number + newdirmode = int(newdirmode, 8) + for segment in repopath.split(os.sep)[:-1]: - p = os.path.join(p, segment) - util.mkdir(p, 0750) + path = os.path.join(path, segment) + util.mkdir(path, newdirmode) - repository.init(path=fullpath) - gitweb.set_descriptions( - config=cfg, - ) - generated = util.getGeneratedFilesDir(config=cfg) - gitweb.generate_project_list( - config=cfg, - path=os.path.join(generated, 'projects.list'), - ) - gitdaemon.set_export_ok( - config=cfg, - ) + repository.init(path=fullpath, mode=newdirmode) + run_hook.build_reposistory_data(cfg) # put the verb back together with the new path newcmd = "%(verb)s '%(path)s'" % dict( @@ -163,14 +177,21 @@ def serve( return newcmd class Main(app.App): + """gitosis-serve program.""" + # W0613 - They also might ignore arguments here, where the descendant + # methods won't. + # pylint: disable-msg=W0613 + def create_parser(self): + """Declare the input for this program.""" parser = super(Main, self).create_parser() parser.set_usage('%prog [OPTS] USER') parser.set_description( 'Allow restricted git operations under DIR') return parser - def handle_args(self, parser, cfg, options, args): + def handle_args(self, parser, cfg, options, args): #pragma: no cover + """Parse the input for this program.""" try: (user,) = args except ValueError: diff --git a/gitosis/ssh.py b/gitosis/ssh.py index a315a5c..bfd52fb 100644 --- a/gitosis/ssh.py +++ b/gitosis/ssh.py @@ -1,14 +1,14 @@ +""" +Gitosis code to handle SSH authorized_keys files +""" import os, errno, re import logging +from gitosis import sshkey +# C0103 - 'log' is a special name +# pylint: disable-msg=C0103 log = logging.getLogger('gitosis.ssh') -_ACCEPTABLE_USER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_.-]*(@[a-zA-Z][a-zA-Z0-9.-]*)?$') - -def isSafeUsername(user): - match = _ACCEPTABLE_USER_RE.match(user) - return (match is not None) - def readKeys(keydir): """ Read SSH public keys from ``keydir/*.pub`` @@ -20,30 +20,42 @@ def readKeys(keydir): if ext != '.pub': continue - if not isSafeUsername(basename): + if not sshkey.isSafeUsername(basename): log.warn('Unsafe SSH username in keyfile: %r', filename) continue path = os.path.join(keydir, filename) - f = file(path) - for line in f: + fp = file(path) + for line in fp: line = line.rstrip('\n') - yield (basename, line) - f.close() + if line.startswith('#'): + continue + line = line.strip() + if len(line) > 0: + yield (basename, sshkey.get_ssh_pubkey(line)) + fp.close() COMMENT = '### autogenerated by gitosis, DO NOT EDIT' +SSH_KEY_ACCEPTED_OPTIONS = ['from'] def generateAuthorizedKeys(keys): - TEMPLATE=('command="gitosis-serve %(user)s",no-port-forwarding,' - +'no-X11-forwarding,no-agent-forwarding,no-pty %(key)s') + """ + Genarate the lines for the Gitosis ~/.ssh/authorized_keys. + """ + TEMPLATE = ('%(options)s %(key)s %(comment)s') + OPTIONS = ('command="gitosis-serve %(user)s",no-port-forwarding,' + +'no-X11-forwarding,no-agent-forwarding,no-pty') yield COMMENT for (user, key) in keys: - yield TEMPLATE % dict(user=user, key=key) + options = OPTIONS % dict(user=user, ) + for k in SSH_KEY_ACCEPTED_OPTIONS: + if k in key.options: + options += (',%s="%s"' % (k, key.options[k])) + yield TEMPLATE % dict(user=user, key=key.key, comment=key.comment, options=options) -_COMMAND_RE = re.compile('^command="(/[^ "]+/)?gitosis-serve [^"]+",no-port-forw' - +'arding,no-X11-forwarding,no-agent-forwardi' - +'ng,no-pty .*') +_GITOSIS_CMD_RE = '(/[^ "]+/)?gitosis-serve [^ "]+$' +_COMMAND_RE = re.compile(_GITOSIS_CMD_RE) def filterAuthorizedKeys(fp): """ @@ -56,16 +68,24 @@ def filterAuthorizedKeys(fp): line = line.rstrip('\n') if line == COMMENT: continue - if _COMMAND_RE.match(line): - continue + try: + key = sshkey.get_ssh_pubkey(line) + if 'command' in key.options and \ + _COMMAND_RE.match(key.options['command']): + continue + except sshkey.MalformedSSHKey: + pass yield line def writeAuthorizedKeys(path, keydir): + """ + Update the Gitosis ~/.ssh/authorized_keys for the new Gitosis SSH key data. + """ tmp = '%s.%d.tmp' % (path, os.getpid()) try: in_ = file(path) - except IOError, e: - if e.errno == errno.ENOENT: + except IOError, ex: #pragma: no cover + if ex.errno == errno.ENOENT: in_ = None else: raise @@ -75,11 +95,11 @@ def writeAuthorizedKeys(path, keydir): try: if in_ is not None: for line in filterAuthorizedKeys(in_): - print >>out, line + print >> out, line keygen = readKeys(keydir) for line in generateAuthorizedKeys(keygen): - print >>out, line + print >> out, line os.fsync(out) finally: diff --git a/gitosis/sshkey.py b/gitosis/sshkey.py new file mode 100644 index 0000000..802145b --- /dev/null +++ b/gitosis/sshkey.py @@ -0,0 +1,203 @@ +""" +Gitosis code to intelligently handle SSH public keys. + +""" +from shlex import shlex +from StringIO import StringIO +import re + +# The 'ecc' and 'ecdh' types are speculative, based on the Internet Draft +# http://www.ietf.org/internet-drafts/draft-green-secsh-ecc-02.txt +SSH_KEY_PROTO2_TYPES = ['ssh-dsa', + 'ssh-dss', + 'ssh-ecc', + 'ssh-ecdh', + 'ssh-rsa'] + +# These options must not have arguments +SSH_KEY_OPTS = ['no-agent-forwarding', + 'no-port-forwarding', + 'no-pty', + 'no-X11-forwarding'] +# These options require arguments +SSH_KEY_OPTS_WITH_ARGS = ['command', + 'environment', + 'from', + 'permitopen', + 'tunnel' ] + +class MalformedSSHKey(Exception): + """Malformed SSH public key""" + +class InsecureSSHKeyUsername(Exception): + """Username contains not allowed characters""" + def __str__(self): + return '%s: %s' % (self.__doc__, ': '.join(self.args)) + +class SSHPublicKey: + """Base class for representing an SSH public key""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + self._options = opts + self._comment = comment + self._username = comment + _ = comment.split(None) + if len(_) > 1: + self._username = _[0] + _ = keydata + + @property + def options(self): + """Returns a dictionary of options used with the SSH public key.""" + return self._options + + @property + def comment(self): + """Returns the comment associated with the SSH public key.""" + return self._comment + + @property + def username(self): + """ + Returns the username from the comment, the first word of the comment. + """ + if isSafeUsername(self._username): + return self._username + else: + raise InsecureSSHKeyUsername(repr(self._username)) + + def options_string(self): + """Return the options array as a suitable string.""" + def _single_option(): + """Convert a single option to a usable string.""" + for (key, val) in self._options.items(): + _ = key + if val is not None: + _ += "=\"%s\"" % (val.replace('"', '\\"'), ) + yield _ + return ','.join(_single_option()) + + @property + def key(self): + """Abstract method""" + raise NotImplementedError() + + @property + def full_key(self): + """Return a full SSH public key line, as found in authorized_keys""" + options = self.options_string() + if len(options) > 0: + options += ' ' + return '%s%s %s' % (options, self.key, self.comment) + + def __str__(self): + return self.full_key + +class SSH1PublicKey(SSHPublicKey): + """Class for representing an SSH public key, protocol version 1""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + SSHPublicKey.__init__(self, opts, keydata, comment) + (self._key_bits, + self._key_exponent, + self._key_modulus) = keydata.split(' ') + @property + def key(self): + """Return just the SSH key data, without options or comments.""" + return '%s %s %s' % (self._key_bits, + self._key_exponent, + self._key_modulus) + +class SSH2PublicKey(SSHPublicKey): + """Class for representing an SSH public key, protocol version 2""" + def __init__(self, opts, keydata, comment): + """Create a new instance.""" + SSHPublicKey.__init__(self, opts, keydata, comment) + (self._key_prefix, self._key_base64) = keydata.split(' ') + @property + def key(self): + """Return just the SSH key data, without options or comments.""" + return '%s %s' % (self._key_prefix, self._key_base64) + +def get_ssh_pubkey(line): + """Take an SSH public key, and return an object representing it.""" + (opts, keydata, comment) = _explode_ssh_key(line) + if keydata.startswith('ssh-'): + return SSH2PublicKey(opts, keydata, comment) + else: + return SSH1PublicKey(opts, keydata, comment) + +def _explode_ssh_key(line): + """ + Break apart a public-key line correct. + - Protocol 1 public keys consist of: + options, bits, exponent, modulus, comment. + - Protocol 2 public key consist of: + options, keytype, base64-encoded key, comment. + - For all options that take an argument, having a quote inside the argument + is valid, and should be in the file as '\"' + - Spaces are also valid in those arguments. + - Options must be seperated by commas. + Seperately return the options, key data and comment. + """ + opts = {} + shl = shlex(StringIO(line.strip()), None, True) + shl.wordchars += '-' + # Treat ',' as whitespace seperation the options + shl.whitespace += ',' + shl.whitespace_split = 1 + # Handle the options first + keydata = None + def _check_eof(tok): + """See if the end was nigh.""" + if tok == shl.eof: + raise MalformedSSHKey("Unexpected end of key") + while True: + tok = shl.get_token() + _check_eof(tok) + # This is the start of the actual key, protocol 1 + if tok.isdigit(): + keydata = tok + expected_key_args = 2 + break + # This is the start of the actual key, protocol 2 + if tok in SSH_KEY_PROTO2_TYPES: + keydata = tok + expected_key_args = 1 + break + if tok in SSH_KEY_OPTS: + opts[tok] = None + continue + if '=' in tok: + (tok, _) = tok.split('=', 1) + if tok in SSH_KEY_OPTS_WITH_ARGS: + opts[tok] = _ + continue + raise MalformedSSHKey("Unknown fragment %r" % (tok, )) + # Now handle the key + # Protocol 2 keys have only 1 argument besides the type + # Protocol 1 keys have 2 arguments after the bit-count. + shl.whitespace_split = 1 + while expected_key_args > 0: + _ = shl.get_token() + _check_eof(_) + keydata += ' '+_ + expected_key_args -= 1 + # Everything that remains is a comment + comment = '' + shl.whitespace = '' + while True: + _ = shl.get_token() + if _ == shl.eof or _ == None: + break + comment += _ + return (opts, keydata, comment) + +_ACCEPTABLE_USER_RE = re.compile( + r'^[a-zA-Z][a-zA-Z0-9_.-]*(@[a-zA-Z][a-zA-Z0-9.-]*)?$' + ) + +def isSafeUsername(user): + """Is the username safe to use a a filename? """ + match = _ACCEPTABLE_USER_RE.match(user) + return (match is not None) diff --git a/gitosis/test/test_gitweb.py b/gitosis/test/test_gitweb.py index 635e555..e538ec7 100644 --- a/gitosis/test/test_gitweb.py +++ b/gitosis/test/test_gitweb.py @@ -222,3 +222,23 @@ def test_description_again(): ) got = readFile(os.path.join(path, 'description')) eq(got, 'foodesc\n') + +def test_escape_filename_normal(): + i = 'abc' + eq(gitweb._escape_filename(i), 'abc') + +def test_escape_filename_slashone(): + i = 'ab\\c' + eq(gitweb._escape_filename(i), 'ab\\\\c') + +def test_escape_filename_slashtwo(): + i = 'ab\\\\c' + eq(gitweb._escape_filename(i), 'ab\\\\\\\\c') + +def test_escape_filename_dollar(): + i = 'abc$' + eq(gitweb._escape_filename(i), 'abc\\$') + +def test_escape_filename_quote(): + i = 'abc"' + eq(gitweb._escape_filename(i), 'abc\\"') diff --git a/gitosis/test/test_init.py b/gitosis/test/test_init.py index fb6b286..dcfa3bf 100644 --- a/gitosis/test/test_init.py +++ b/gitosis/test/test_init.py @@ -2,95 +2,13 @@ from nose.tools import eq_ as eq from gitosis.test.util import assert_raises, maketemp import os -from ConfigParser import RawConfigParser from gitosis import init from gitosis import repository +from gitosis.configutil import GitosisRawConfigParser from gitosis.test import util -def test_ssh_extract_user_simple(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') - eq(got, 'fakeuser@fakehost') - -def test_ssh_extract_user_domain(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost.example.com') - eq(got, 'fakeuser@fakehost.example.com') - -def test_ssh_extract_user_domain_dashes(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@ridiculously-long.example.com') - eq(got, 'fakeuser@ridiculously-long.example.com') - -def test_ssh_extract_user_underscore(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake_user@example.com') - eq(got, 'fake_user@example.com') - -def test_ssh_extract_user_dot(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u.ser@example.com') - eq(got, 'fake.u.ser@example.com') - -def test_ssh_extract_user_dash(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u-ser@example.com') - eq(got, 'fake.u-ser@example.com') - -def test_ssh_extract_user_no_at(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser') - eq(got, 'fakeuser') - -def test_ssh_extract_user_caps(): - got = init.ssh_extract_user( - 'ssh-somealgo ' - +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= Fake.User@Domain.Example.Com') - eq(got, 'Fake.User@Domain.Example.Com') - -def test_ssh_extract_user_bad(): - e = assert_raises( - init.InsecureSSHKeyUsername, - init.ssh_extract_user, - 'ssh-somealgo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ER3%#@e%') - eq(str(e), "Username contains not allowed characters: 'ER3%#@e%'") - def test_init_admin_repository(): tmp = maketemp() admin_repository = os.path.join(tmp, 'admin.git') @@ -101,10 +19,12 @@ def test_init_admin_repository(): +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') user = 'jdoe' + cfg = GitosisRawConfigParser() init.init_admin_repository( git_dir=admin_repository, pubkey=pubkey, user=user, + config=cfg, ) eq(os.listdir(tmp), ['admin.git']) hook = os.path.join( @@ -129,9 +49,9 @@ def test_init_admin_repository(): # the only thing guaranteed of initial config file ordering is # that [gitosis] is first got = util.readFile(os.path.join(export_dir, 'gitosis.conf')) + # We can't gaurentee this anymore got = got.splitlines()[0] eq(got, '[gitosis]') - cfg = RawConfigParser() cfg.read(os.path.join(export_dir, 'gitosis.conf')) eq(sorted(cfg.sections()), sorted([ diff --git a/gitosis/test/test_repository.py b/gitosis/test/test_repository.py index 6ce4129..6bc9e76 100644 --- a/gitosis/test/test_repository.py +++ b/gitosis/test/test_repository.py @@ -36,6 +36,13 @@ def test_init_exist_dir(): check_mode(path, 0710, is_dir=True) check_bare(path) +def test_init_custom_perm(): + tmp = maketemp() + path = os.path.join(tmp, 'repo.git') + repository.init(path, mode=0711) + check_mode(path, 0711, is_dir=True) + check_bare(path) + def test_init_exist_git(): tmp = maketemp() path = os.path.join(tmp, 'repo.git') diff --git a/gitosis/test/test_run_hook.py b/gitosis/test/test_run_hook.py index db01e0c..f935375 100644 --- a/gitosis/test/test_run_hook.py +++ b/gitosis/test/test_run_hook.py @@ -13,16 +13,31 @@ def test_post_update_simple(): os.mkdir(repos) admin_repository = os.path.join(repos, 'gitosis-admin.git') pubkey = ( - 'ssh-somealgo ' + 'ssh-rsa ' +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') user = 'theadmin' + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'repositories', repos) + generated = os.path.join(tmp, 'generated') + os.mkdir(generated) + cfg.set('gitosis', 'generate-files-in', generated) + ssh = os.path.join(tmp, 'ssh') + os.mkdir(ssh) + cfg.set( + 'gitosis', + 'ssh-authorized-keys-path', + os.path.join(ssh, 'authorized_keys'), + ) + init.init_admin_repository( git_dir=admin_repository, pubkey=pubkey, user=user, + config=cfg, ) repository.init(path=os.path.join(repos, 'forweb.git')) repository.init(path=os.path.join(repos, 'fordaemon.git')) @@ -50,26 +65,13 @@ owner = John Doe description = blah blah """), ('keydir/jdoe.pub', - 'ssh-somealgo ' + 'ssh-rsa ' +'0123456789ABCDEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' +'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' +'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' +'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= jdoe@host.example.com'), ], ) - cfg = RawConfigParser() - cfg.add_section('gitosis') - cfg.set('gitosis', 'repositories', repos) - generated = os.path.join(tmp, 'generated') - os.mkdir(generated) - cfg.set('gitosis', 'generate-files-in', generated) - ssh = os.path.join(tmp, 'ssh') - os.mkdir(ssh) - cfg.set( - 'gitosis', - 'ssh-authorized-keys-path', - os.path.join(ssh, 'authorized_keys'), - ) run_hook.post_update( cfg=cfg, git_dir=admin_repository, @@ -91,5 +93,5 @@ forweb.git John+Doe got = os.listdir(ssh) eq(got, ['authorized_keys']) got = readFile(os.path.join(ssh, 'authorized_keys')).splitlines(True) - assert 'command="gitosis-serve jdoe",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-somealgo 0123456789ABCDEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= jdoe@host.example.com\n' in got, \ + assert 'command="gitosis-serve jdoe",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa 0123456789ABCDEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= jdoe@host.example.com\n' in got, \ "SSH authorized_keys line for jdoe not found: %r" % got diff --git a/gitosis/test/test_serve.py b/gitosis/test/test_serve.py index f1c1930..4b414c4 100644 --- a/gitosis/test/test_serve.py +++ b/gitosis/test/test_serve.py @@ -199,7 +199,24 @@ def test_simple_read_dash(): ) eq(got, "git-upload-pack '%s/foo.git'" % tmp) -def test_simple_read_space(): +def test_simple_read_absolute(): + tmp = util.maketemp() + full_path = os.path.join(tmp, 'foo.git') + repository.init(full_path) + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'repositories', tmp) + cfg.add_section('group foo') + cfg.set('group foo', 'members', 'jdoe') + cfg.set('group foo', 'readonly', 'foo') + got = serve.serve( + cfg=cfg, + user='jdoe', + command="git-upload-pack '%s'" % (full_path, ), + ) + eq(got, "git-upload-pack '%s/foo.git'" % tmp) + +def test_simple_read_leading_slash(): tmp = util.maketemp() repository.init(os.path.join(tmp, 'foo.git')) cfg = RawConfigParser() @@ -211,11 +228,11 @@ def test_simple_read_space(): got = serve.serve( cfg=cfg, user='jdoe', - command="git upload-pack 'foo'", + command="git-upload-pack '/foo'", ) - eq(got, "git upload-pack '%s/foo.git'" % tmp) + eq(got, "git-upload-pack '%s/foo.git'" % tmp) -def test_simple_write_dash(): +def test_simple_write(): tmp = util.maketemp() repository.init(os.path.join(tmp, 'foo.git')) cfg = RawConfigParser() @@ -317,6 +334,31 @@ def test_push_inits_subdir_parent_missing(): eq(os.listdir(foo), ['bar.git']) assert os.path.isfile(os.path.join(repositories, 'foo', 'bar.git', 'HEAD')) +def test_push_inits_subdir_parent_missing_custom_perms(): + tmp = util.maketemp() + cfg = RawConfigParser() + cfg.add_section('gitosis') + repositories = os.path.join(tmp, 'repositories') + os.mkdir(repositories) + cfg.set('gitosis', 'repositories', repositories) + cfg.set('gitosis', 'dirmode', '0711') + generated = os.path.join(tmp, 'generated') + os.mkdir(generated) + cfg.set('gitosis', 'generate-files-in', generated) + cfg.add_section('group foo') + cfg.set('group foo', 'members', 'jdoe') + cfg.set('group foo', 'writable', 'foo/bar') + serve.serve( + cfg=cfg, + user='jdoe', + command="git-receive-pack 'foo/bar.git'", + ) + eq(os.listdir(repositories), ['foo']) + foo = os.path.join(repositories, 'foo') + util.check_mode(foo, 0711, is_dir=True) + eq(os.listdir(foo), ['bar.git']) + assert os.path.isfile(os.path.join(repositories, 'foo', 'bar.git', 'HEAD')) + def test_push_inits_subdir_parent_exists(): tmp = util.maketemp() cfg = RawConfigParser() diff --git a/gitosis/test/test_ssh.py b/gitosis/test/test_ssh.py index fc6ecbc..75effd5 100644 --- a/gitosis/test/test_ssh.py +++ b/gitosis/test/test_ssh.py @@ -1,9 +1,10 @@ -from nose.tools import eq_ as eq, assert_raises +from nose.tools import eq_ as eq, assert_raises, raises import os from cStringIO import StringIO from gitosis import ssh +from gitosis import sshkey from gitosis.test.util import mkdir, maketemp, writeFile, readFile def _key(s): @@ -13,15 +14,13 @@ KEY_1 = _key(""" ssh-rsa +v5XLsUrLsHOKy7Stob1lHZM17YCCNXplcKfbpIztS2PujyixOaBev1ku6H6ny gUXfuYVzY+PmfTLviSwD3UETxEkR/jlBURACDQARJdUxpgt9XG2Lbs8bhOjonAPapxrH0o 9O8R0Y6Pm1Vh+H2U0B4UBhPgEframpeJYedijBxBV5aq3yUvHkXpcjM/P0gsKqr036k= j -unk@gunk -""") +unk@gunk""") KEY_2 = _key(""" ssh-rsa 4BX2TxZoD3Og2zNjHwaMhVEa5/NLnPcw+Z02TDR0IGJrrqXk7YlfR3oz+Wb/Eb Ctli20SoWY0Ur8kBEF/xR4hRslZ2U8t0PAJhr8cq5mifhok/gAdckmSzjD67QJ68uZbga8 ZwIAo7y/BU7cD3Y9UdVZykG34NiijHZLlCBo/TnobXjFIPXvFbfgQ3y8g+akwocFVcQ= f -roop@snoop -""") +roop@snoop""") class ReadKeys_Test(object): def test_empty(self): @@ -54,7 +53,9 @@ class ReadKeys_Test(object): writeFile(os.path.join(keydir, 'jdoe.pub'), KEY_1+'\n') gen = ssh.readKeys(keydir=keydir) - eq(gen.next(), ('jdoe', KEY_1)) + (who, key) = gen.next() + eq(who, 'jdoe') + eq(key.full_key, KEY_1) assert_raises(StopIteration, gen.next) def test_two(self): @@ -65,7 +66,7 @@ class ReadKeys_Test(object): writeFile(os.path.join(keydir, 'wsmith.pub'), KEY_2+'\n') gen = ssh.readKeys(keydir=keydir) - got = frozenset(gen) + got = frozenset( (i, j.full_key) for (i, j) in gen) eq(got, frozenset([ @@ -90,7 +91,7 @@ class ReadKeys_Test(object): writeFile(os.path.join(keydir, 'jdoe.pub'), KEY_1+'\n'+KEY_2+'\n') gen = ssh.readKeys(keydir=keydir) - got = frozenset(gen) + got = frozenset( (i, j.full_key) for (i, j) in gen) eq(got, frozenset([ @@ -101,8 +102,8 @@ class ReadKeys_Test(object): class GenerateAuthorizedKeys_Test(object): def test_simple(self): def k(): - yield ('jdoe', KEY_1) - yield ('wsmith', KEY_2) + yield ('jdoe', sshkey.get_ssh_pubkey(KEY_1)) + yield ('wsmith', sshkey.get_ssh_pubkey(KEY_2)) gen = ssh.generateAuthorizedKeys(k()) eq(gen.next(), ssh.COMMENT) eq(gen.next(), ( @@ -191,11 +192,5 @@ baz path=path, keydir=keydir) got = readFile(path) - eq(got, '''\ -# foo -bar -baz -### autogenerated by gitosis, DO NOT EDIT -command="gitosis-serve jdoe",no-port-forwarding,\ -no-X11-forwarding,no-agent-forwarding,no-pty %(key_1)s -''' % dict(key_1=KEY_1)) + eq(got, '''# foo\nbar\nbaz\n### autogenerated by gitosis, DO NOT EDIT\ncommand="gitosis-serve jdoe",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %(key_1)s\n''' % dict(key_1=KEY_1)) + diff --git a/gitosis/test/test_sshkey.py b/gitosis/test/test_sshkey.py new file mode 100644 index 0000000..09863fa --- /dev/null +++ b/gitosis/test/test_sshkey.py @@ -0,0 +1,99 @@ +from nose.tools import eq_ as eq, assert_raises, raises + +from gitosis import sshkey + +def test_sshkey_username_simple(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') + got = _.username + eq(got, 'fakeuser@fakehost') + +def test_sshkey_username_domain(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost.example.com') + got = _.username + eq(got, 'fakeuser@fakehost.example.com') + +def test_sshkey_username_domain_dashes(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ' + +'fakeuser@ridiculously-long.example.com') + got = _.username + eq(got, 'fakeuser@ridiculously-long.example.com') + +def test_sshkey_username_underscore(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake_user@example.com') + got = _.username + eq(got, 'fake_user@example.com') + +def test_sshkey_username_dot(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u.ser@example.com') + got = _.username + eq(got, 'fake.u.ser@example.com') + +def test_sshkey_username_dash(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fake.u-ser@example.com') + got = _.username + eq(got, 'fake.u-ser@example.com') + +def test_sshkey_username_no_at(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser') + got = _.username + eq(got, 'fakeuser') + +def test_sshkey_username_caps(): + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= Fake.User@Domain.Example.Com') + got = _.username + eq(got, 'Fake.User@Domain.Example.Com') + +@raises(sshkey.InsecureSSHKeyUsername) +def test_sshkey_username_bad(): + # The '#' and characters after it are part of an actual comment in the file + # and are ignored. + try: + _ = sshkey.get_ssh_pubkey( + 'ssh-rsa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ER3%#@e%') + got = _.username + except sshkey.InsecureSSHKeyUsername, e: + eq(str(e), "Username contains not allowed characters: 'ER3%'") + raise e diff --git a/gitosis/test/test_util.py b/gitosis/test/test_util.py new file mode 100644 index 0000000..c53f76c --- /dev/null +++ b/gitosis/test/test_util.py @@ -0,0 +1,65 @@ +from nose.tools import eq_ as eq, assert_raises + +import os +import errno + +from ConfigParser import RawConfigParser + +from gitosis import util + +# Nose interferes with this test case, and 'except' block inside _sysfunc does +# not recieve the error. +#def test_sysfunc_raise_ignore(): +# def foo(): +# os.mkdir('/does/not/exist/anywhere') +# util._sysfunc(foo, [errno.EEXIST]) + +def test_sysfunc_raise_catch(): + def foo(): + raise OSError(errno.EEXIST) + assert_raises(OSError, util._sysfunc, foo, [errno.ENOENT]) + +def test_getRepositoryDir_cfg_missing(): + cfg = RawConfigParser() + d = util.getRepositoryDir(cfg) + eq(d, os.path.expanduser('~/repositories')) + +def test_getRepositoryDir_cfg_empty(): + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'repositories', '') + d = util.getRepositoryDir(cfg) + eq(d, os.path.expanduser('~/')) + +def test_getRepositoryDir_cfg_relative(): + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'repositories', 'foobar') + d = util.getRepositoryDir(cfg) + eq(d, os.path.expanduser('~/foobar')) + +def test_getRepositoryDir_cfg_absolute(): + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'repositories', '/var/gitroot') + d = util.getRepositoryDir(cfg) + eq(d, '/var/gitroot') + +def test_getGeneratedFilesDir_cfg_missing(): + cfg = RawConfigParser() + d = util.getGeneratedFilesDir(cfg) + eq(d, os.path.expanduser('~/gitosis')) + +def test_getGeneratedFilesDir_cfg_empty(): + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'generate-files-in', '') + d = util.getGeneratedFilesDir(cfg) + eq(d, '') + +def test_getGeneratedFilesDir_cfg_set(): + cfg = RawConfigParser() + cfg.add_section('gitosis') + cfg.set('gitosis', 'generate-files-in', 'foobar') + d = util.getGeneratedFilesDir(cfg) + eq(d, 'foobar') diff --git a/gitosis/test/test_zzz_app.py b/gitosis/test/test_zzz_app.py new file mode 100644 index 0000000..27ba697 --- /dev/null +++ b/gitosis/test/test_zzz_app.py @@ -0,0 +1,108 @@ +from nose.tools import eq_ as eq, assert_raises + +from gitosis import app +from gitosis import init +from gitosis import run_hook +from gitosis import serve +import sys +import os + +class TestMain(app.App): + def handle_args(self, parser, cfg, options, args): + """Parse the input for this program.""" + pass + +def test_app_setup_basic_logging(): + main = TestMain() + main.setup_basic_logging() + +def test_app_create_parser(): + main = TestMain() + parser = main.create_parser() + +def test_app_create_parser_parse_none(): + main = TestMain() + parser = main.create_parser() + (options, args) = parser.parse_args([]) + print '%r' % (options, ) + eq(args, []) + eq(options, {'config': os.path.expanduser('~/.gitosis.conf')}) + +def test_app_create_parser_parse_config(): + main = TestMain() + parser = main.create_parser() + (options, args) = parser.parse_args(['--config=/dev/null']) + eq(args, []) + eq(options, {'config': '/dev/null'}) + +def test_app_create_config(): + main = TestMain() + cfg = main.create_config(None) + +def test_app_read_config_empty(): + main = TestMain() + cfg = main.create_config(None) + parser = main.create_parser() + (options, args) = parser.parse_args(['--config=/dev/null']) + main.read_config(options, cfg) + +def test_app_read_config_does_not_exist(): + main = TestMain() + cfg = main.create_config(None) + parser = main.create_parser() + (options, args) = parser.parse_args(['--config=/does/not/exist']) + assert_raises(app.ConfigFileDoesNotExistError, main.read_config, options, cfg) + + +def test_app_setup_logging_default(): + main = TestMain() + cfg = main.create_config(None) + main.setup_logging(cfg) + +def test_app_setup_logging_goodname(): + main = TestMain() + cfg = main.create_config(None) + cfg.add_section('gitosis') + cfg.set('gitosis', 'loglevel', 'WARN') + main.setup_logging(cfg) + +def test_app_setup_logging_badname(): + main = TestMain() + cfg = main.create_config(None) + cfg.add_section('gitosis') + cfg.set('gitosis', 'loglevel', 'FOOBAR') + main.setup_logging(cfg) + +def test_appinit_create_parser(): + main = init.Main() + parser = main.create_parser() + +def test_appinit_read_config(): + main = init.Main() + cfg = main.create_config(None) + parser = main.create_parser() + (options, args) = parser.parse_args(['--config=/does/not/exist']) + main.read_config(options, cfg) + +def test_apprunhook_create_parser(): + main = run_hook.Main() + parser = main.create_parser() + +def test_appserve_create_parser(): + main = serve.Main() + parser = main.create_parser() + +# We must call this test last +def test_zzz_app_main(): + class Main(TestMain): + def read_config(self, options, cfg): + """Ignore errors that result from non-existent config file.""" + pass + oldargv = sys.argv + sys.argv = [] + main = Main() + main.run() + #parser = self.create_parser() + #(options, args) = parser.parse_args() + #cfg = self.create_config(options) + sys.argv = oldargv diff --git a/gitosis/util.py b/gitosis/util.py index 479b2e9..248cb7b 100644 --- a/gitosis/util.py +++ b/gitosis/util.py @@ -1,17 +1,57 @@ +""" +Some utility functions for Gitosis +""" import errno import os +import shutil from ConfigParser import NoSectionError, NoOptionError -def mkdir(*a, **kw): +def mkdir(newdir, mode=0777): + """ + Like os.mkdir, but already existing directories do not raise an error. + """ + _sysfunc(os.mkdir, [errno.EEXIST], newdir, mode) + +def unlink(filename): + """ + Like os.unlink, but non-existing files do not raise an error. + """ + _sysfunc(os.unlink, [errno.ENOENT], filename) + +def rmtree(directory): + """ + Like shutil.rmtree, but non-existing trees do not raise an error. + """ + _sysfunc(shutil.rmtree, [errno.ENOENT], directory) + +def _sysfunc(func, ignore, *args, **kwds): + """ + Run the specified function, ignoring the specified errno if raised, and + raising other errors. + """ + # We use * and ** correctly here + # pylint: disable-msg=W0142 + if not ignore: # pragma: no cover + ignore = [] try: - os.mkdir(*a, **kw) - except OSError, e: - if e.errno == errno.EEXIST: + func(*args, **kwds) + except OSError, ex: + if ex.errno in ignore: pass else: raise def getRepositoryDir(config): + """ + Find the location of the Git repositories. + + Tries: + - ``gitosis.repositories`` configuration key (see note) + - ``~/repositories`` + + Note: If the configuration key is a relative path, it is appended onto + the homedir for the gitosis user. + """ repositories = os.path.expanduser('~') try: path = config.get('gitosis', 'repositories') @@ -22,6 +62,13 @@ def getRepositoryDir(config): return repositories def getGeneratedFilesDir(config): + """ + Find the location for the generated Gitosis files. + + Tries: + - ``gitosis.generate-files-in`` configuration key + - ``~/gitosis`` + """ try: generated = config.get('gitosis', 'generate-files-in') except (NoSectionError, NoOptionError): diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..74e3089 --- /dev/null +++ b/pylintrc @@ -0,0 +1,305 @@ +# lint Python modules using external checkers. +# +# This is the main checker controling the other ones and the reports +# generation. It is itself both a raw checker and an astng checker in order +# to: +# * handle message activation / deactivation at the module level +# * handle some basic but necessary stats'data (number of classes, methods...) +# +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Profiled execution. +profile=no + +# Add <file or directory> to the black list. It should be a base name, not a +# path. You may set this option multiple times. +ignore=CVS + +# Pickle collected data for later comparisons. +persistent=yes + +# Set the cache size for astng objects. +cache-size=500 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + + +[MESSAGES CONTROL] + +# Enable only checker(s) with the given id(s). This option conflict with the +# disable-checker option +#enable-checker= + +# Enable all checker(s) except those with the given id(s). This option conflict +# with the disable-checker option +#disable-checker= + +# Enable all messages in the listed categories. +#enable-msg-cat= + +# Disable all messages in the listed categories. +#disable-msg-cat= + +# Enable the message(s) with the given id(s). +#enable-msg= + +# Disable the message(s) with the given id(s). +#disable-msg= + + +[REPORTS] + +# set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html +output-format=text + +# Include message's id in output +include-ids=no + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + +# Tells wether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note).You have access to the variables errors warning, statement which +# respectivly contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (R0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Add a comment according to your evaluation note. This is used by the global +# evaluation report (R0004). +comment=no + +# Enable the report(s) with the given id(s). +#enable-report= + +# Disable the report(s) with the given id(s). +#disable-report= + + +# checks for : +# * doc strings +# * modules / classes / functions / methods / arguments / variables name +# * number of arguments, local variables, branchs, returns and statements in +# functions, methods +# * required module attributes +# * dangerous default values as arguments +# * redefinition of function / method / class +# * uses of the global statement +# +[BASIC] + +# Required attributes for module, separated by a comma +required-attributes= + +# Regular expression which should only match functions or classes name which do +# not require a docstring +no-docstring-rgx=__.*__ + +# Regular expression which should only match correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression which should only match correct module level names +const-rgx=(([A-Z_][A-Z1-9_]*)|(__.*__))$ + +# Regular expression which should only match correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression which should only match correct function names +function-rgx=[a-zA-Z_][a-z0A-Z-9_]{2,30}$ + +# Regular expression which should only match correct method names +method-rgx=[a-zA-Z_][a-z0A-Z-9_]{2,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct list comprehension / +# generator expression variable names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,fp,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter,apply,input + + +# checks for +# * unused variables / imports +# * undefined variables +# * redefinition of variable from builtins or from an outer scope +# * use of variable before assigment +# +[VARIABLES] + +# Tells wether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching names used for dummy variables (i.e. not used). +dummy-variables-rgx=_|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + + +# try to find bugs in the code using type inference +# +[TYPECHECK] + +# Tells wether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# When zope mode is activated, consider the acquired-members option to ignore +# access to some undefined attributes. +zope=no + +# List of members which are usually get through zope's acquisition mecanism and +# so shouldn't trigger E0201 when accessed (need zope=yes to be considered). +acquired-members=REQUEST,acl_users,aq_parent + + +# checks for : +# * methods without self as first argument +# * overridden methods signature +# * access only to existant members via self +# * attributes not defined in the __init__ method +# * supported interfaces implementation +# * unreachable code +# +[CLASSES] + +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. +ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + + +# checks for +# * external modules dependencies +# * relative / wildcard imports +# * cyclic imports +# * uses of deprecated modules +# +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,string,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report R0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report R0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report R0402 must +# not be disabled) +int-import-graph= + + +# checks for sign of poor/misdesign: +# * number of methods, attributes, local variables... +# * size, complexity of functions, methods +# +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branchs=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +# checks for : +# * unauthorized constructions +# * strict indentation +# * line length +# * use of <> instead of != +# +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + + +# checks for similarities and duplicated code. This computation may be +# memory / CPU intensive, so you should disable it if you experiments some +# problems. +# +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + + +# checks for: +# * warning notes in the code like FIXME, XXX +# * PEP 263: source code with non ascii character but no encoding declaration +# +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO |