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