aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--gitosis/access.py3
-rw-r--r--gitosis/app.py41
-rw-r--r--gitosis/configutil.py56
-rw-r--r--gitosis/gitdaemon.py101
-rw-r--r--gitosis/gitweb.py104
-rw-r--r--gitosis/group.py25
-rw-r--r--gitosis/init.py113
-rw-r--r--gitosis/repository.py31
-rw-r--r--gitosis/run_hook.py63
-rw-r--r--gitosis/serve.py67
-rw-r--r--gitosis/ssh.py66
-rw-r--r--gitosis/sshkey.py203
-rw-r--r--gitosis/test/test_gitweb.py20
-rw-r--r--gitosis/test/test_init.py88
-rw-r--r--gitosis/test/test_repository.py7
-rw-r--r--gitosis/test/test_run_hook.py34
-rw-r--r--gitosis/test/test_serve.py50
-rw-r--r--gitosis/test/test_ssh.py31
-rw-r--r--gitosis/test/test_sshkey.py99
-rw-r--r--gitosis/test/test_util.py65
-rw-r--r--gitosis/test/test_zzz_app.py108
-rw-r--r--gitosis/util.py55
-rw-r--r--pylintrc305
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