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