##// END OF EJS Templates
py3: add b'' prefixes to the LFS server module...
Matt Harbison -
r41469:7a11e4e5 default
parent child Browse files
Show More
@@ -1,349 +1,349
1 # wireprotolfsserver.py - lfs protocol server side implementation
1 # wireprotolfsserver.py - lfs protocol server side implementation
2 #
2 #
3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from __future__ import absolute_import
8 from __future__ import absolute_import
9
9
10 import datetime
10 import datetime
11 import errno
11 import errno
12 import json
12 import json
13 import traceback
13 import traceback
14
14
15 from mercurial.hgweb import (
15 from mercurial.hgweb import (
16 common as hgwebcommon,
16 common as hgwebcommon,
17 )
17 )
18
18
19 from mercurial import (
19 from mercurial import (
20 exthelper,
20 exthelper,
21 pycompat,
21 pycompat,
22 util,
22 util,
23 wireprotoserver,
23 wireprotoserver,
24 )
24 )
25
25
26 from . import blobstore
26 from . import blobstore
27
27
28 HTTP_OK = hgwebcommon.HTTP_OK
28 HTTP_OK = hgwebcommon.HTTP_OK
29 HTTP_CREATED = hgwebcommon.HTTP_CREATED
29 HTTP_CREATED = hgwebcommon.HTTP_CREATED
30 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
30 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
31 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
31 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
32 HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED
32 HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED
33 HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE
33 HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE
34 HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE
34 HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE
35
35
36 eh = exthelper.exthelper()
36 eh = exthelper.exthelper()
37
37
38 @eh.wrapfunction(wireprotoserver, 'handlewsgirequest')
38 @eh.wrapfunction(wireprotoserver, 'handlewsgirequest')
39 def handlewsgirequest(orig, rctx, req, res, checkperm):
39 def handlewsgirequest(orig, rctx, req, res, checkperm):
40 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
40 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
41 request if it is left unprocessed by the wrapped method.
41 request if it is left unprocessed by the wrapped method.
42 """
42 """
43 if orig(rctx, req, res, checkperm):
43 if orig(rctx, req, res, checkperm):
44 return True
44 return True
45
45
46 if not rctx.repo.ui.configbool('experimental', 'lfs.serve'):
46 if not rctx.repo.ui.configbool(b'experimental', b'lfs.serve'):
47 return False
47 return False
48
48
49 if not util.safehasattr(rctx.repo.svfs, 'lfslocalblobstore'):
49 if not util.safehasattr(rctx.repo.svfs, 'lfslocalblobstore'):
50 return False
50 return False
51
51
52 if not req.dispatchpath:
52 if not req.dispatchpath:
53 return False
53 return False
54
54
55 try:
55 try:
56 if req.dispatchpath == b'.git/info/lfs/objects/batch':
56 if req.dispatchpath == b'.git/info/lfs/objects/batch':
57 checkperm(rctx, req, 'pull')
57 checkperm(rctx, req, b'pull')
58 return _processbatchrequest(rctx.repo, req, res)
58 return _processbatchrequest(rctx.repo, req, res)
59 # TODO: reserve and use a path in the proposed http wireprotocol /api/
59 # TODO: reserve and use a path in the proposed http wireprotocol /api/
60 # namespace?
60 # namespace?
61 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
61 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
62 return _processbasictransfer(rctx.repo, req, res,
62 return _processbasictransfer(rctx.repo, req, res,
63 lambda perm:
63 lambda perm:
64 checkperm(rctx, req, perm))
64 checkperm(rctx, req, perm))
65 return False
65 return False
66 except hgwebcommon.ErrorResponse as e:
66 except hgwebcommon.ErrorResponse as e:
67 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
67 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
68 # in the wrapped function. Should this be moved back to hgweb to
68 # in the wrapped function. Should this be moved back to hgweb to
69 # be a common handler?
69 # be a common handler?
70 for k, v in e.headers:
70 for k, v in e.headers:
71 res.headers[k] = v
71 res.headers[k] = v
72 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
72 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
73 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
73 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
74 return True
74 return True
75
75
76 def _sethttperror(res, code, message=None):
76 def _sethttperror(res, code, message=None):
77 res.status = hgwebcommon.statusmessage(code, message=message)
77 res.status = hgwebcommon.statusmessage(code, message=message)
78 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
78 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
79 res.setbodybytes(b'')
79 res.setbodybytes(b'')
80
80
81 def _logexception(req):
81 def _logexception(req):
82 """Write information about the current exception to wsgi.errors."""
82 """Write information about the current exception to wsgi.errors."""
83 tb = pycompat.sysbytes(traceback.format_exc())
83 tb = pycompat.sysbytes(traceback.format_exc())
84 errorlog = req.rawenv[r'wsgi.errors']
84 errorlog = req.rawenv[b'wsgi.errors']
85
85
86 uri = b''
86 uri = b''
87 if req.apppath:
87 if req.apppath:
88 uri += req.apppath
88 uri += req.apppath
89 uri += b'/' + req.dispatchpath
89 uri += b'/' + req.dispatchpath
90
90
91 errorlog.write(b"Exception happened while processing request '%s':\n%s" %
91 errorlog.write(b"Exception happened while processing request '%s':\n%s" %
92 (uri, tb))
92 (uri, tb))
93
93
94 def _processbatchrequest(repo, req, res):
94 def _processbatchrequest(repo, req, res):
95 """Handle a request for the Batch API, which is the gateway to granting file
95 """Handle a request for the Batch API, which is the gateway to granting file
96 access.
96 access.
97
97
98 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
98 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
99 """
99 """
100
100
101 # Mercurial client request:
101 # Mercurial client request:
102 #
102 #
103 # HOST: localhost:$HGPORT
103 # HOST: localhost:$HGPORT
104 # ACCEPT: application/vnd.git-lfs+json
104 # ACCEPT: application/vnd.git-lfs+json
105 # ACCEPT-ENCODING: identity
105 # ACCEPT-ENCODING: identity
106 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
106 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
107 # Content-Length: 125
107 # Content-Length: 125
108 # Content-Type: application/vnd.git-lfs+json
108 # Content-Type: application/vnd.git-lfs+json
109 #
109 #
110 # {
110 # {
111 # "objects": [
111 # "objects": [
112 # {
112 # {
113 # "oid": "31cf...8e5b"
113 # "oid": "31cf...8e5b"
114 # "size": 12
114 # "size": 12
115 # }
115 # }
116 # ]
116 # ]
117 # "operation": "upload"
117 # "operation": "upload"
118 # }
118 # }
119
119
120 if req.method != b'POST':
120 if req.method != b'POST':
121 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED)
121 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED)
122 return True
122 return True
123
123
124 if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json':
124 if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json':
125 _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE)
125 _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE)
126 return True
126 return True
127
127
128 if req.headers[b'Accept'] != b'application/vnd.git-lfs+json':
128 if req.headers[b'Accept'] != b'application/vnd.git-lfs+json':
129 _sethttperror(res, HTTP_NOT_ACCEPTABLE)
129 _sethttperror(res, HTTP_NOT_ACCEPTABLE)
130 return True
130 return True
131
131
132 # XXX: specify an encoding?
132 # XXX: specify an encoding?
133 lfsreq = json.loads(req.bodyfh.read())
133 lfsreq = json.loads(req.bodyfh.read())
134
134
135 # If no transfer handlers are explicitly requested, 'basic' is assumed.
135 # If no transfer handlers are explicitly requested, 'basic' is assumed.
136 if 'basic' not in lfsreq.get('transfers', ['basic']):
136 if 'basic' not in lfsreq.get('transfers', ['basic']):
137 _sethttperror(res, HTTP_BAD_REQUEST,
137 _sethttperror(res, HTTP_BAD_REQUEST,
138 b'Only the basic LFS transfer handler is supported')
138 b'Only the basic LFS transfer handler is supported')
139 return True
139 return True
140
140
141 operation = lfsreq.get('operation')
141 operation = lfsreq.get('operation')
142 if operation not in ('upload', 'download'):
142 if operation not in ('upload', 'download'):
143 _sethttperror(res, HTTP_BAD_REQUEST,
143 _sethttperror(res, HTTP_BAD_REQUEST,
144 b'Unsupported LFS transfer operation: %s' % operation)
144 b'Unsupported LFS transfer operation: %s' % operation)
145 return True
145 return True
146
146
147 localstore = repo.svfs.lfslocalblobstore
147 localstore = repo.svfs.lfslocalblobstore
148
148
149 objects = [p for p in _batchresponseobjects(req, lfsreq.get('objects', []),
149 objects = [p for p in _batchresponseobjects(req, lfsreq.get('objects', []),
150 operation, localstore)]
150 operation, localstore)]
151
151
152 rsp = {
152 rsp = {
153 'transfer': 'basic',
153 'transfer': 'basic',
154 'objects': objects,
154 'objects': objects,
155 }
155 }
156
156
157 res.status = hgwebcommon.statusmessage(HTTP_OK)
157 res.status = hgwebcommon.statusmessage(HTTP_OK)
158 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
158 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
159 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
159 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
160
160
161 return True
161 return True
162
162
163 def _batchresponseobjects(req, objects, action, store):
163 def _batchresponseobjects(req, objects, action, store):
164 """Yield one dictionary of attributes for the Batch API response for each
164 """Yield one dictionary of attributes for the Batch API response for each
165 object in the list.
165 object in the list.
166
166
167 req: The parsedrequest for the Batch API request
167 req: The parsedrequest for the Batch API request
168 objects: The list of objects in the Batch API object request list
168 objects: The list of objects in the Batch API object request list
169 action: 'upload' or 'download'
169 action: 'upload' or 'download'
170 store: The local blob store for servicing requests"""
170 store: The local blob store for servicing requests"""
171
171
172 # Successful lfs-test-server response to solict an upload:
172 # Successful lfs-test-server response to solict an upload:
173 # {
173 # {
174 # u'objects': [{
174 # u'objects': [{
175 # u'size': 12,
175 # u'size': 12,
176 # u'oid': u'31cf...8e5b',
176 # u'oid': u'31cf...8e5b',
177 # u'actions': {
177 # u'actions': {
178 # u'upload': {
178 # u'upload': {
179 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
179 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
180 # u'expires_at': u'0001-01-01T00:00:00Z',
180 # u'expires_at': u'0001-01-01T00:00:00Z',
181 # u'header': {
181 # u'header': {
182 # u'Accept': u'application/vnd.git-lfs'
182 # u'Accept': u'application/vnd.git-lfs'
183 # }
183 # }
184 # }
184 # }
185 # }
185 # }
186 # }]
186 # }]
187 # }
187 # }
188
188
189 # TODO: Sort out the expires_at/expires_in/authenticated keys.
189 # TODO: Sort out the expires_at/expires_in/authenticated keys.
190
190
191 for obj in objects:
191 for obj in objects:
192 # Convert unicode to ASCII to create a filesystem path
192 # Convert unicode to ASCII to create a filesystem path
193 oid = obj.get('oid').encode('ascii')
193 oid = obj.get('oid').encode('ascii')
194 rsp = {
194 rsp = {
195 'oid': oid,
195 'oid': oid,
196 'size': obj.get('size'), # XXX: should this check the local size?
196 'size': obj.get('size'), # XXX: should this check the local size?
197 #'authenticated': True,
197 #'authenticated': True,
198 }
198 }
199
199
200 exists = True
200 exists = True
201 verifies = False
201 verifies = False
202
202
203 # Verify an existing file on the upload request, so that the client is
203 # Verify an existing file on the upload request, so that the client is
204 # solicited to re-upload if it corrupt locally. Download requests are
204 # solicited to re-upload if it corrupt locally. Download requests are
205 # also verified, so the error can be flagged in the Batch API response.
205 # also verified, so the error can be flagged in the Batch API response.
206 # (Maybe we can use this to short circuit the download for `hg verify`,
206 # (Maybe we can use this to short circuit the download for `hg verify`,
207 # IFF the client can assert that the remote end is an hg server.)
207 # IFF the client can assert that the remote end is an hg server.)
208 # Otherwise, it's potentially overkill on download, since it is also
208 # Otherwise, it's potentially overkill on download, since it is also
209 # verified as the file is streamed to the caller.
209 # verified as the file is streamed to the caller.
210 try:
210 try:
211 verifies = store.verify(oid)
211 verifies = store.verify(oid)
212 if verifies and action == 'upload':
212 if verifies and action == b'upload':
213 # The client will skip this upload, but make sure it remains
213 # The client will skip this upload, but make sure it remains
214 # available locally.
214 # available locally.
215 store.linkfromusercache(oid)
215 store.linkfromusercache(oid)
216 except IOError as inst:
216 except IOError as inst:
217 if inst.errno != errno.ENOENT:
217 if inst.errno != errno.ENOENT:
218 _logexception(req)
218 _logexception(req)
219
219
220 rsp['error'] = {
220 rsp['error'] = {
221 'code': 500,
221 'code': 500,
222 'message': inst.strerror or 'Internal Server Server'
222 'message': inst.strerror or 'Internal Server Server'
223 }
223 }
224 yield rsp
224 yield rsp
225 continue
225 continue
226
226
227 exists = False
227 exists = False
228
228
229 # Items are always listed for downloads. They are dropped for uploads
229 # Items are always listed for downloads. They are dropped for uploads
230 # IFF they already exist locally.
230 # IFF they already exist locally.
231 if action == 'download':
231 if action == b'download':
232 if not exists:
232 if not exists:
233 rsp['error'] = {
233 rsp['error'] = {
234 'code': 404,
234 'code': 404,
235 'message': "The object does not exist"
235 'message': "The object does not exist"
236 }
236 }
237 yield rsp
237 yield rsp
238 continue
238 continue
239
239
240 elif not verifies:
240 elif not verifies:
241 rsp['error'] = {
241 rsp['error'] = {
242 'code': 422, # XXX: is this the right code?
242 'code': 422, # XXX: is this the right code?
243 'message': "The object is corrupt"
243 'message': "The object is corrupt"
244 }
244 }
245 yield rsp
245 yield rsp
246 continue
246 continue
247
247
248 elif verifies:
248 elif verifies:
249 yield rsp # Skip 'actions': already uploaded
249 yield rsp # Skip 'actions': already uploaded
250 continue
250 continue
251
251
252 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
252 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
253
253
254 def _buildheader():
254 def _buildheader():
255 # The spec doesn't mention the Accept header here, but avoid
255 # The spec doesn't mention the Accept header here, but avoid
256 # a gratuitous deviation from lfs-test-server in the test
256 # a gratuitous deviation from lfs-test-server in the test
257 # output.
257 # output.
258 hdr = {
258 hdr = {
259 'Accept': 'application/vnd.git-lfs'
259 'Accept': 'application/vnd.git-lfs'
260 }
260 }
261
261
262 auth = req.headers.get('Authorization', '')
262 auth = req.headers.get(b'Authorization', b'')
263 if auth.startswith('Basic '):
263 if auth.startswith(b'Basic '):
264 hdr['Authorization'] = auth
264 hdr['Authorization'] = auth
265
265
266 return hdr
266 return hdr
267
267
268 rsp['actions'] = {
268 rsp['actions'] = {
269 '%s' % action: {
269 '%s' % action: {
270 'href': '%s%s/.hg/lfs/objects/%s'
270 'href': '%s%s/.hg/lfs/objects/%s'
271 % (req.baseurl, req.apppath, oid),
271 % (req.baseurl, req.apppath, oid),
272 # datetime.isoformat() doesn't include the 'Z' suffix
272 # datetime.isoformat() doesn't include the 'Z' suffix
273 "expires_at": expiresat.strftime('%Y-%m-%dT%H:%M:%SZ'),
273 "expires_at": expiresat.strftime('%Y-%m-%dT%H:%M:%SZ'),
274 'header': _buildheader(),
274 'header': _buildheader(),
275 }
275 }
276 }
276 }
277
277
278 yield rsp
278 yield rsp
279
279
280 def _processbasictransfer(repo, req, res, checkperm):
280 def _processbasictransfer(repo, req, res, checkperm):
281 """Handle a single file upload (PUT) or download (GET) action for the Basic
281 """Handle a single file upload (PUT) or download (GET) action for the Basic
282 Transfer Adapter.
282 Transfer Adapter.
283
283
284 After determining if the request is for an upload or download, the access
284 After determining if the request is for an upload or download, the access
285 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
285 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
286 before accessing the files.
286 before accessing the files.
287
287
288 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
288 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
289 """
289 """
290
290
291 method = req.method
291 method = req.method
292 oid = req.dispatchparts[-1]
292 oid = req.dispatchparts[-1]
293 localstore = repo.svfs.lfslocalblobstore
293 localstore = repo.svfs.lfslocalblobstore
294
294
295 if len(req.dispatchparts) != 4:
295 if len(req.dispatchparts) != 4:
296 _sethttperror(res, HTTP_NOT_FOUND)
296 _sethttperror(res, HTTP_NOT_FOUND)
297 return True
297 return True
298
298
299 if method == b'PUT':
299 if method == b'PUT':
300 checkperm('upload')
300 checkperm(b'upload')
301
301
302 # TODO: verify Content-Type?
302 # TODO: verify Content-Type?
303
303
304 existed = localstore.has(oid)
304 existed = localstore.has(oid)
305
305
306 # TODO: how to handle timeouts? The body proxy handles limiting to
306 # TODO: how to handle timeouts? The body proxy handles limiting to
307 # Content-Length, but what happens if a client sends less than it
307 # Content-Length, but what happens if a client sends less than it
308 # says it will?
308 # says it will?
309
309
310 statusmessage = hgwebcommon.statusmessage
310 statusmessage = hgwebcommon.statusmessage
311 try:
311 try:
312 localstore.download(oid, req.bodyfh)
312 localstore.download(oid, req.bodyfh)
313 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
313 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
314 except blobstore.LfsCorruptionError:
314 except blobstore.LfsCorruptionError:
315 _logexception(req)
315 _logexception(req)
316
316
317 # XXX: Is this the right code?
317 # XXX: Is this the right code?
318 res.status = statusmessage(422, b'corrupt blob')
318 res.status = statusmessage(422, b'corrupt blob')
319
319
320 # There's no payload here, but this is the header that lfs-test-server
320 # There's no payload here, but this is the header that lfs-test-server
321 # sends back. This eliminates some gratuitous test output conditionals.
321 # sends back. This eliminates some gratuitous test output conditionals.
322 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
322 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
323 res.setbodybytes(b'')
323 res.setbodybytes(b'')
324
324
325 return True
325 return True
326 elif method == b'GET':
326 elif method == b'GET':
327 checkperm('pull')
327 checkperm(b'pull')
328
328
329 res.status = hgwebcommon.statusmessage(HTTP_OK)
329 res.status = hgwebcommon.statusmessage(HTTP_OK)
330 res.headers[b'Content-Type'] = b'application/octet-stream'
330 res.headers[b'Content-Type'] = b'application/octet-stream'
331
331
332 try:
332 try:
333 # TODO: figure out how to send back the file in chunks, instead of
333 # TODO: figure out how to send back the file in chunks, instead of
334 # reading the whole thing. (Also figure out how to send back
334 # reading the whole thing. (Also figure out how to send back
335 # an error status if an IOError occurs after a partial write
335 # an error status if an IOError occurs after a partial write
336 # in that case. Here, everything is read before starting.)
336 # in that case. Here, everything is read before starting.)
337 res.setbodybytes(localstore.read(oid))
337 res.setbodybytes(localstore.read(oid))
338 except blobstore.LfsCorruptionError:
338 except blobstore.LfsCorruptionError:
339 _logexception(req)
339 _logexception(req)
340
340
341 # XXX: Is this the right code?
341 # XXX: Is this the right code?
342 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
342 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
343 res.setbodybytes(b'')
343 res.setbodybytes(b'')
344
344
345 return True
345 return True
346 else:
346 else:
347 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED,
347 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED,
348 message=b'Unsupported LFS transfer method: %s' % method)
348 message=b'Unsupported LFS transfer method: %s' % method)
349 return True
349 return True
General Comments 0
You need to be logged in to leave comments. Login now