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