Add support for a post-init hook script
[gitosis.git] / gitosis / serve.py
1 """
2 Enforce git-shell to only serve allowed by access control policy.
3 directory. The client should refer to them without any extra directory
4 prefix. Repository names are forced to match ALLOW_RE.
5 """
6
7 import logging
8
9 import sys, os, re
10 import subprocess
11
12 from gitosis import access
13 from gitosis import repository
14 from gitosis import gitweb
15 from gitosis import gitdaemon
16 from gitosis import app
17 from gitosis import util
18 from gitosis.repository import GitInitError
19
20 from ConfigParser import NoSectionError, NoOptionError
21
22 log = logging.getLogger('gitosis.serve')
23
24 ALLOW_RE = re.compile("^'/*(?P<path>[a-zA-Z0-9][a-zA-Z0-9@._-]*(/[a-zA-Z0-9][a-zA-Z0-9@._-]*)*)'$")
25
26 COMMANDS_READONLY = [
27     'git-upload-pack',
28     'git upload-pack',
29     'git-upload-archive',
30     'git upload-archive',
31     ]
32
33 COMMANDS_WRITE = [
34     'git-receive-pack',
35     'git receive-pack',
36     ]
37
38 class ServingError(Exception):
39     """Serving error"""
40
41     def __str__(self):
42         return '%s' % self.__doc__
43
44 class CommandMayNotContainNewlineError(ServingError):
45     """Command may not contain newline"""
46
47 class UnknownCommandError(ServingError):
48     """Unknown command denied"""
49
50 class CommandTooLongError(ServingError):
51     """Command malformed - too many args"""
52
53 class UnsafeArgumentsError(ServingError):
54     """Arguments to command look dangerous"""
55
56 class AccessDenied(ServingError):
57     """Access denied to repository"""
58
59 class WriteAccessDenied(AccessDenied):
60     """Repository write access denied"""
61
62 class ReadAccessDenied(AccessDenied):
63     """Repository read access denied"""
64
65 def serve(
66     cfg,
67     user,
68     command,
69     ):
70     if '\n' in command:
71         raise CommandMayNotContainNewlineError()
72
73     try:
74         verb, args = command.split(None, 1)
75     except ValueError:
76         # all known "git-foo" commands take one argument; improve
77         # if/when needed
78         raise CommandTooLongError()
79
80     if verb == 'git':
81         try:
82             subverb, args = args.split(None, 1)
83         except ValueError:
84             # all known "git foo" commands take one argument; improve
85             # if/when needed
86             raise CommandTooLongError()
87         verb = '%s %s' % (verb, subverb)
88
89     if (verb not in COMMANDS_WRITE
90         and verb not in COMMANDS_READONLY):
91         raise UnknownCommandError()
92
93     match = ALLOW_RE.match(args)
94     if match is None:
95         raise UnsafeArgumentsError()
96
97     path = match.group('path')
98
99     # write access is always sufficient
100     newpath = access.haveAccess(
101         config=cfg,
102         user=user,
103         mode='writable',
104         path=path)
105
106     if newpath is None:
107         # didn't have write access; try once more with the popular
108         # misspelling
109         newpath = access.haveAccess(
110             config=cfg,
111             user=user,
112             mode='writeable',
113             path=path)
114         if newpath is not None:
115             log.warning(
116                 'Repository %r config has typo "writeable", '
117                 +'should be "writable"',
118                 path,
119                 )
120
121     if newpath is None:
122         # didn't have write access
123
124         newpath = access.haveAccess(
125             config=cfg,
126             user=user,
127             mode='readonly',
128             path=path)
129
130         if newpath is None:
131             raise ReadAccessDenied()
132
133         if verb in COMMANDS_WRITE:
134             # didn't have write access and tried to write
135             raise WriteAccessDenied()
136
137     (topdir, relpath) = newpath
138     assert not relpath.endswith('.git'), \
139            'git extension should have been stripped: %r' % relpath
140     repopath = '%s.git' % relpath
141     fullpath = os.path.join(topdir, repopath)
142     if not os.path.exists(fullpath):
143         # it doesn't exist on the filesystem, but the configuration
144         # refers to it, we're serving a write request, and the user is
145         # authorized to do that: create the repository on the fly
146
147         # create leading directories
148         p = topdir
149         for segment in repopath.split(os.sep)[:-1]:
150             p = os.path.join(p, segment)
151             util.mkdir(p, 0751)
152
153         repository.init(path=fullpath)
154         gitweb.set_descriptions(
155             config=cfg,
156             )
157         generated = util.getGeneratedFilesDir(config=cfg)
158         gitweb.generate_project_list(
159             config=cfg,
160             path=os.path.join(generated, 'projects.list'),
161             )
162         gitdaemon.set_export_ok(
163             config=cfg,
164             )
165
166         try:
167             post_init_hook = cfg.get('hooks', 'post-init')
168             log.info ('Running post-init hook %s for new repository' % post_init_hook)
169             returncode = subprocess.call(
170                 args=[ post_init_hook, repopath ],
171                 stdout=sys.stderr
172                 )
173             if returncode != 0:
174                 raise GitInitError('exit status %d from post-init hook %s' % (returncode, post_init_hook))
175
176         except (NoSectionError, NoOptionError):
177             pass
178
179     # put the verb back together with the new path
180     newcmd = "%(verb)s '%(path)s'" % dict(
181         verb=verb,
182         path=fullpath,
183         )
184     return newcmd
185
186 class Main(app.App):
187     def create_parser(self):
188         parser = super(Main, self).create_parser()
189         parser.set_usage('%prog [OPTS] USER')
190         parser.set_description(
191             'Allow restricted git operations under DIR')
192         return parser
193
194     def handle_args(self, parser, cfg, options, args):
195         try:
196             (user,) = args
197         except ValueError:
198             parser.error('Missing argument USER.')
199
200         main_log = logging.getLogger('gitosis.serve.main')
201         os.umask(0022)
202
203         cmd = os.environ.get('SSH_ORIGINAL_COMMAND', None)
204         if cmd is None:
205             main_log.error('Need SSH_ORIGINAL_COMMAND in environment.')
206             sys.exit(1)
207
208         main_log.debug('Got command %(cmd)r' % dict(
209             cmd=cmd,
210             ))
211
212         os.chdir(os.path.expanduser('~'))
213
214         try:
215             newcmd = serve(
216                 cfg=cfg,
217                 user=user,
218                 command=cmd,
219                 )
220         except ServingError, e:
221             main_log.error('%s', e)
222             sys.exit(1)
223
224         main_log.debug('Serving %s', newcmd)
225         os.execvp('git', ['git', 'shell', '-c', newcmd])
226         main_log.error('Cannot execute git-shell.')
227         sys.exit(1)