##// END OF EJS Templates
fix(git-lfs): fixed security problem with allowing off-chain attacks to replace OID data without validating hash for already present oids....
super-admin -
r1300:a680a605 default
parent child Browse files
Show More
@@ -1,303 +1,314 b''
1 # RhodeCode VCSServer provides access to different vcs backends via network.
1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 # Copyright (C) 2014-2023 RhodeCode GmbH
2 # Copyright (C) 2014-2023 RhodeCode GmbH
3 #
3 #
4 # This program is free software; you can redistribute it and/or modify
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
7 # (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17 import hashlib
18 import re
18 import re
19 import logging
19 import logging
20
20
21 from gunicorn.http.errors import NoMoreData
21 from gunicorn.http.errors import NoMoreData
22 from pyramid.config import Configurator
22 from pyramid.config import Configurator
23 from pyramid.response import Response, FileIter
23 from pyramid.response import Response, FileIter
24 from pyramid.httpexceptions import (
24 from pyramid.httpexceptions import (
25 HTTPBadRequest, HTTPNotImplemented, HTTPNotFound, HTTPForbidden,
25 HTTPBadRequest, HTTPNotImplemented, HTTPNotFound, HTTPForbidden,
26 HTTPUnprocessableEntity)
26 HTTPUnprocessableEntity)
27
27
28 from vcsserver.lib.ext_json import json
28 from vcsserver.lib.ext_json import json
29 from vcsserver.git_lfs.lib import OidHandler, LFSOidStore
29 from vcsserver.git_lfs.lib import OidHandler, LFSOidStore
30 from vcsserver.git_lfs.utils import safe_result, get_cython_compat_decorator
30 from vcsserver.git_lfs.utils import safe_result, get_cython_compat_decorator
31 from vcsserver.lib.str_utils import safe_int
31 from vcsserver.lib.str_utils import safe_int
32
32
33 log = logging.getLogger(__name__)
33 log = logging.getLogger(__name__)
34
34
35
35
36 GIT_LFS_CONTENT_TYPE = 'application/vnd.git-lfs' # +json ?
36 GIT_LFS_CONTENT_TYPE = 'application/vnd.git-lfs' # +json ?
37 GIT_LFS_PROTO_PAT = re.compile(r'^/(.+)/(info/lfs/(.+))')
37 GIT_LFS_PROTO_PAT = re.compile(r'^/(.+)/(info/lfs/(.+))')
38
38
39
39
40 def write_response_error(http_exception, text=None):
40 def write_response_error(http_exception, text=None):
41 content_type = GIT_LFS_CONTENT_TYPE + '+json'
41 content_type = GIT_LFS_CONTENT_TYPE + '+json'
42 _exception = http_exception(content_type=content_type)
42 _exception = http_exception(content_type=content_type)
43 _exception.content_type = content_type
43 _exception.content_type = content_type
44 if text:
44 if text:
45 _exception.body = json.dumps({'message': text})
45 _exception.body = json.dumps({'message': text})
46 log.debug('LFS: writing response of type %s to client with text:%s',
46 log.debug('LFS: writing response of type %s to client with text:%s',
47 http_exception, text)
47 http_exception, text)
48 return _exception
48 return _exception
49
49
50
50
51 class AuthHeaderRequired:
51 class AuthHeaderRequired:
52 """
52 """
53 Decorator to check if request has proper auth-header
53 Decorator to check if request has proper auth-header
54 """
54 """
55
55
56 def __call__(self, func):
56 def __call__(self, func):
57 return get_cython_compat_decorator(self.__wrapper, func)
57 return get_cython_compat_decorator(self.__wrapper, func)
58
58
59 def __wrapper(self, func, *fargs, **fkwargs):
59 def __wrapper(self, func, *fargs, **fkwargs):
60 request = fargs[1]
60 request = fargs[1]
61 auth = request.authorization
61 auth = request.authorization
62 if not auth:
62 if not auth:
63 log.debug('No auth header found, returning 403')
63 log.debug('No auth header found, returning 403')
64 return write_response_error(HTTPForbidden)
64 return write_response_error(HTTPForbidden)
65 return func(*fargs[1:], **fkwargs)
65 return func(*fargs[1:], **fkwargs)
66
66
67
67
68 # views
68 # views
69
69
70 def lfs_objects(request):
70 def lfs_objects(request):
71 # indicate not supported, V1 API
71 # indicate not supported, V1 API
72 log.warning('LFS: v1 api not supported, reporting it back to client')
72 log.warning('LFS: v1 api not supported, reporting it back to client')
73 return write_response_error(HTTPNotImplemented, 'LFS: v1 api not supported')
73 return write_response_error(HTTPNotImplemented, 'LFS: v1 api not supported')
74
74
75
75
76 @AuthHeaderRequired()
76 @AuthHeaderRequired()
77 def lfs_objects_batch(request):
77 def lfs_objects_batch(request):
78 """
78 """
79 The client sends the following information to the Batch endpoint to transfer some objects:
79 The client sends the following information to the Batch endpoint to transfer some objects:
80
80
81 operation - Should be download or upload.
81 operation - Should be download or upload.
82 transfers - An optional Array of String identifiers for transfer
82 transfers - An optional Array of String identifiers for transfer
83 adapters that the client has configured. If omitted, the basic
83 adapters that the client has configured. If omitted, the basic
84 transfer adapter MUST be assumed by the server.
84 transfer adapter MUST be assumed by the server.
85 objects - An Array of objects to download.
85 objects - An Array of objects to download.
86 oid - String OID of the LFS object.
86 oid - String OID of the LFS object.
87 size - Integer byte size of the LFS object. Must be at least zero.
87 size - Integer byte size of the LFS object. Must be at least zero.
88 """
88 """
89 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
89 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
90 auth = request.authorization
90 auth = request.authorization
91 repo = request.matchdict.get('repo')
91 repo = request.matchdict.get('repo')
92 data = request.json
92 data = request.json
93 operation = data.get('operation')
93 operation = data.get('operation')
94 http_scheme = request.registry.git_lfs_http_scheme
94 http_scheme = request.registry.git_lfs_http_scheme
95
95
96 if operation not in ('download', 'upload'):
96 if operation not in ('download', 'upload'):
97 log.debug('LFS: unsupported operation:%s', operation)
97 log.debug('LFS: unsupported operation:%s', operation)
98 return write_response_error(
98 return write_response_error(
99 HTTPBadRequest, f'unsupported operation mode: `{operation}`')
99 HTTPBadRequest, f'unsupported operation mode: `{operation}`')
100
100
101 if 'objects' not in data:
101 if 'objects' not in data:
102 log.debug('LFS: missing objects data')
102 log.debug('LFS: missing objects data')
103 return write_response_error(
103 return write_response_error(
104 HTTPBadRequest, 'missing objects data')
104 HTTPBadRequest, 'missing objects data')
105
105
106 log.debug('LFS: handling operation of type: %s', operation)
106 log.debug('LFS: handling operation of type: %s', operation)
107
107
108 objects = []
108 objects = []
109 for o in data['objects']:
109 for o in data['objects']:
110 try:
110 try:
111 oid = o['oid']
111 oid = o['oid']
112 obj_size = o['size']
112 obj_size = o['size']
113 except KeyError:
113 except KeyError:
114 log.exception('LFS, failed to extract data')
114 log.exception('LFS, failed to extract data')
115 return write_response_error(
115 return write_response_error(
116 HTTPBadRequest, 'unsupported data in objects')
116 HTTPBadRequest, 'unsupported data in objects')
117
117
118 obj_data = {'oid': oid}
118 obj_data = {'oid': oid}
119 if http_scheme == 'http':
119 if http_scheme == 'http':
120 # Note(marcink): when using http, we might have a custom port
120 # Note(marcink): when using http, we might have a custom port
121 # so we skip setting it to http, url dispatch then wont generate a port in URL
121 # so we skip setting it to http, url dispatch then wont generate a port in URL
122 # for development we need this
122 # for development we need this
123 http_scheme = None
123 http_scheme = None
124
124
125 obj_href = request.route_url('lfs_objects_oid', repo=repo, oid=oid,
125 obj_href = request.route_url('lfs_objects_oid', repo=repo, oid=oid,
126 _scheme=http_scheme)
126 _scheme=http_scheme)
127 obj_verify_href = request.route_url('lfs_objects_verify', repo=repo,
127 obj_verify_href = request.route_url('lfs_objects_verify', repo=repo,
128 _scheme=http_scheme)
128 _scheme=http_scheme)
129 store = LFSOidStore(
129 store = LFSOidStore(
130 oid, repo, store_location=request.registry.git_lfs_store_path)
130 oid, repo, store_location=request.registry.git_lfs_store_path)
131 handler = OidHandler(
131 handler = OidHandler(
132 store, repo, auth, oid, obj_size, obj_data,
132 store, repo, auth, oid, obj_size, obj_data,
133 obj_href, obj_verify_href)
133 obj_href, obj_verify_href)
134
134
135 # this verifies also OIDs
135 # this verifies also OIDs
136 actions, errors = handler.exec_operation(operation)
136 actions, errors = handler.exec_operation(operation)
137 if errors:
137 if errors:
138 log.warning('LFS: got following errors: %s', errors)
138 log.warning('LFS: got following errors: %s', errors)
139 obj_data['errors'] = errors
139 obj_data['errors'] = errors
140
140
141 if actions:
141 if actions:
142 obj_data['actions'] = actions
142 obj_data['actions'] = actions
143
143
144 obj_data['size'] = obj_size
144 obj_data['size'] = obj_size
145 obj_data['authenticated'] = True
145 obj_data['authenticated'] = True
146 objects.append(obj_data)
146 objects.append(obj_data)
147
147
148 result = {'objects': objects, 'transfer': 'basic'}
148 result = {'objects': objects, 'transfer': 'basic'}
149 log.debug('LFS Response %s', safe_result(result))
149 log.debug('LFS Response %s', safe_result(result))
150
150
151 return result
151 return result
152
152
153
153
154 def lfs_objects_oid_upload(request):
154 def lfs_objects_oid_upload(request):
155 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
155 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
156 repo = request.matchdict.get('repo')
156 repo = request.matchdict.get('repo')
157 oid = request.matchdict.get('oid')
157 oid = request.matchdict.get('oid')
158 store = LFSOidStore(
158 store = LFSOidStore(
159 oid, repo, store_location=request.registry.git_lfs_store_path)
159 oid, repo, store_location=request.registry.git_lfs_store_path)
160 engine = store.get_engine(mode='wb')
160 engine = store.get_engine(mode='wb')
161 log.debug('LFS: starting chunked write of LFS oid: %s to storage', oid)
161 log.debug('LFS: starting chunked write of LFS oid: %s to storage', oid)
162
162
163 # validate if OID is not by any chance already in the store
164 if store.has_oid():
165 log.debug('LFS: oid %s exists in store', oid)
166 return {'upload': 'ok', 'state': 'in-store'}
167
163 body = request.environ['wsgi.input']
168 body = request.environ['wsgi.input']
164
169
170 digest = hashlib.sha256()
165 with engine as f:
171 with engine as f:
166 blksize = 64 * 1024 # 64kb
172 blksize = 64 * 1024 # 64kb
167 while True:
173 while True:
168 # read in chunks as stream comes in from Gunicorn
174 # read in chunks as stream comes in from Gunicorn
169 # this is a specific Gunicorn support function.
175 # this is a specific Gunicorn support function.
170 # might work differently on waitress
176 # might work differently on waitress
171 try:
177 try:
172 chunk = body.read(blksize)
178 chunk = body.read(blksize)
173 except NoMoreData:
179 except NoMoreData:
174 chunk = None
180 chunk = None
175
181
176 if not chunk:
182 if not chunk:
177 break
183 break
184 f.write(chunk)
185 digest.update(chunk)
178
186
179 f.write(chunk)
187 hex_digest = digest.hexdigest()
188 digest_check = hex_digest == oid
189 if not digest_check:
190 engine.cleanup() # trigger cleanup so we don't save mismatch OID into the store
191 return write_response_error(
192 HTTPBadRequest, f'oid {oid} does not match expected sha {hex_digest}')
180
193
181 return {'upload': 'ok'}
194 return {'upload': 'ok', 'state': 'written'}
182
195
183
196
184 def lfs_objects_oid_download(request):
197 def lfs_objects_oid_download(request):
185 repo = request.matchdict.get('repo')
198 repo = request.matchdict.get('repo')
186 oid = request.matchdict.get('oid')
199 oid = request.matchdict.get('oid')
187
200
188 store = LFSOidStore(
201 store = LFSOidStore(
189 oid, repo, store_location=request.registry.git_lfs_store_path)
202 oid, repo, store_location=request.registry.git_lfs_store_path)
190 if not store.has_oid():
203 if not store.has_oid():
191 log.debug('LFS: oid %s does not exists in store', oid)
204 log.debug('LFS: oid %s does not exists in store', oid)
192 return write_response_error(
205 return write_response_error(
193 HTTPNotFound, f'requested file with oid `{oid}` not found in store')
206 HTTPNotFound, f'requested file with oid `{oid}` not found in store')
194
207
195 # TODO(marcink): support range header ?
208 # TODO(marcink): support range header ?
196 # Range: bytes=0-, `bytes=(\d+)\-.*`
209 # Range: bytes=0-, `bytes=(\d+)\-.*`
197
210
198 f = open(store.oid_path, 'rb')
211 f = open(store.oid_path, 'rb')
199 response = Response(
212 response = Response(
200 content_type='application/octet-stream', app_iter=FileIter(f))
213 content_type='application/octet-stream', app_iter=FileIter(f))
201 response.headers.add('X-RC-LFS-Response-Oid', str(oid))
214 response.headers.add('X-RC-LFS-Response-Oid', str(oid))
202 return response
215 return response
203
216
204
217
205 def lfs_objects_verify(request):
218 def lfs_objects_verify(request):
206 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
219 request.response.content_type = GIT_LFS_CONTENT_TYPE + '+json'
207 repo = request.matchdict.get('repo')
220 repo = request.matchdict.get('repo')
208
221
209 data = request.json
222 data = request.json
210 oid = data.get('oid')
223 oid = data.get('oid')
211 size = safe_int(data.get('size'))
224 size = safe_int(data.get('size'))
212
225
213 if not (oid and size):
226 if not (oid and size):
214 return write_response_error(
227 return write_response_error(
215 HTTPBadRequest, 'missing oid and size in request data')
228 HTTPBadRequest, 'missing oid and size in request data')
216
229
217 store = LFSOidStore(
230 store = LFSOidStore(
218 oid, repo, store_location=request.registry.git_lfs_store_path)
231 oid, repo, store_location=request.registry.git_lfs_store_path)
219 if not store.has_oid():
232 if not store.has_oid():
220 log.debug('LFS: oid %s does not exists in store', oid)
233 log.debug('LFS: oid %s does not exists in store', oid)
221 return write_response_error(
234 return write_response_error(
222 HTTPNotFound, f'oid `{oid}` does not exists in store')
235 HTTPNotFound, f'oid `{oid}` does not exists in store')
223
236
224 store_size = store.size_oid()
237 store_size = store.size_oid()
225 if store_size != size:
238 if store_size != size:
226 msg = 'requested file size mismatch store size:{} requested:{}'.format(
239 msg = f'requested file size mismatch store size:{store_size} requested:{size}'
227 store_size, size)
240 return write_response_error(HTTPUnprocessableEntity, msg)
228 return write_response_error(
229 HTTPUnprocessableEntity, msg)
230
241
231 return {'message': {'size': 'ok', 'in_store': 'ok'}}
242 return {'message': {'size': store_size, 'oid': oid}}
232
243
233
244
234 def lfs_objects_lock(request):
245 def lfs_objects_lock(request):
235 return write_response_error(
246 return write_response_error(
236 HTTPNotImplemented, 'GIT LFS locking api not supported')
247 HTTPNotImplemented, 'GIT LFS locking api not supported')
237
248
238
249
239 def not_found(request):
250 def not_found(request):
240 return write_response_error(
251 return write_response_error(
241 HTTPNotFound, 'request path not found')
252 HTTPNotFound, 'request path not found')
242
253
243
254
244 def lfs_disabled(request):
255 def lfs_disabled(request):
245 return write_response_error(
256 return write_response_error(
246 HTTPNotImplemented, 'GIT LFS disabled for this repo')
257 HTTPNotImplemented, 'GIT LFS disabled for this repo')
247
258
248
259
249 def git_lfs_app(config):
260 def git_lfs_app(config):
250
261
251 # v1 API deprecation endpoint
262 # v1 API deprecation endpoint
252 config.add_route('lfs_objects',
263 config.add_route('lfs_objects',
253 '/{repo:.*?[^/]}/info/lfs/objects')
264 '/{repo:.*?[^/]}/info/lfs/objects')
254 config.add_view(lfs_objects, route_name='lfs_objects',
265 config.add_view(lfs_objects, route_name='lfs_objects',
255 request_method='POST', renderer='json')
266 request_method='POST', renderer='json')
256
267
257 # locking API
268 # locking API
258 config.add_route('lfs_objects_lock',
269 config.add_route('lfs_objects_lock',
259 '/{repo:.*?[^/]}/info/lfs/locks')
270 '/{repo:.*?[^/]}/info/lfs/locks')
260 config.add_view(lfs_objects_lock, route_name='lfs_objects_lock',
271 config.add_view(lfs_objects_lock, route_name='lfs_objects_lock',
261 request_method=('POST', 'GET'), renderer='json')
272 request_method=('POST', 'GET'), renderer='json')
262
273
263 config.add_route('lfs_objects_lock_verify',
274 config.add_route('lfs_objects_lock_verify',
264 '/{repo:.*?[^/]}/info/lfs/locks/verify')
275 '/{repo:.*?[^/]}/info/lfs/locks/verify')
265 config.add_view(lfs_objects_lock, route_name='lfs_objects_lock_verify',
276 config.add_view(lfs_objects_lock, route_name='lfs_objects_lock_verify',
266 request_method=('POST', 'GET'), renderer='json')
277 request_method=('POST', 'GET'), renderer='json')
267
278
268 # batch API
279 # batch API
269 config.add_route('lfs_objects_batch',
280 config.add_route('lfs_objects_batch',
270 '/{repo:.*?[^/]}/info/lfs/objects/batch')
281 '/{repo:.*?[^/]}/info/lfs/objects/batch')
271 config.add_view(lfs_objects_batch, route_name='lfs_objects_batch',
282 config.add_view(lfs_objects_batch, route_name='lfs_objects_batch',
272 request_method='POST', renderer='json')
283 request_method='POST', renderer='json')
273
284
274 # oid upload/download API
285 # oid upload/download API
275 config.add_route('lfs_objects_oid',
286 config.add_route('lfs_objects_oid',
276 '/{repo:.*?[^/]}/info/lfs/objects/{oid}')
287 '/{repo:.*?[^/]}/info/lfs/objects/{oid}')
277 config.add_view(lfs_objects_oid_upload, route_name='lfs_objects_oid',
288 config.add_view(lfs_objects_oid_upload, route_name='lfs_objects_oid',
278 request_method='PUT', renderer='json')
289 request_method='PUT', renderer='json')
279 config.add_view(lfs_objects_oid_download, route_name='lfs_objects_oid',
290 config.add_view(lfs_objects_oid_download, route_name='lfs_objects_oid',
280 request_method='GET', renderer='json')
291 request_method='GET', renderer='json')
281
292
282 # verification API
293 # verification API
283 config.add_route('lfs_objects_verify',
294 config.add_route('lfs_objects_verify',
284 '/{repo:.*?[^/]}/info/lfs/verify')
295 '/{repo:.*?[^/]}/info/lfs/verify')
285 config.add_view(lfs_objects_verify, route_name='lfs_objects_verify',
296 config.add_view(lfs_objects_verify, route_name='lfs_objects_verify',
286 request_method='POST', renderer='json')
297 request_method='POST', renderer='json')
287
298
288 # not found handler for API
299 # not found handler for API
289 config.add_notfound_view(not_found, renderer='json')
300 config.add_notfound_view(not_found, renderer='json')
290
301
291
302
292 def create_app(git_lfs_enabled, git_lfs_store_path, git_lfs_http_scheme):
303 def create_app(git_lfs_enabled, git_lfs_store_path, git_lfs_http_scheme):
293 config = Configurator()
304 config = Configurator()
294 if git_lfs_enabled:
305 if git_lfs_enabled:
295 config.include(git_lfs_app)
306 config.include(git_lfs_app)
296 config.registry.git_lfs_store_path = git_lfs_store_path
307 config.registry.git_lfs_store_path = git_lfs_store_path
297 config.registry.git_lfs_http_scheme = git_lfs_http_scheme
308 config.registry.git_lfs_http_scheme = git_lfs_http_scheme
298 else:
309 else:
299 # not found handler for API, reporting disabled LFS support
310 # not found handler for API, reporting disabled LFS support
300 config.add_notfound_view(lfs_disabled, renderer='json')
311 config.add_notfound_view(lfs_disabled, renderer='json')
301
312
302 app = config.make_wsgi_app()
313 app = config.make_wsgi_app()
303 return app
314 return app
@@ -1,177 +1,185 b''
1 # RhodeCode VCSServer provides access to different vcs backends via network.
1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 # Copyright (C) 2014-2023 RhodeCode GmbH
2 # Copyright (C) 2014-2023 RhodeCode GmbH
3 #
3 #
4 # This program is free software; you can redistribute it and/or modify
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
7 # (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
18 import os
18 import os
19 import shutil
19 import shutil
20 import logging
20 import logging
21 from collections import OrderedDict
21 from collections import OrderedDict
22
22
23 log = logging.getLogger(__name__)
23 log = logging.getLogger(__name__)
24
24
25
25
26 class OidHandler:
26 class OidHandler:
27
27
28 def __init__(self, store, repo_name, auth, oid, obj_size, obj_data, obj_href,
28 def __init__(self, store, repo_name, auth, oid, obj_size, obj_data, obj_href,
29 obj_verify_href=None):
29 obj_verify_href=None):
30 self.current_store = store
30 self.current_store = store
31 self.repo_name = repo_name
31 self.repo_name = repo_name
32 self.auth = auth
32 self.auth = auth
33 self.oid = oid
33 self.oid = oid
34 self.obj_size = obj_size
34 self.obj_size = obj_size
35 self.obj_data = obj_data
35 self.obj_data = obj_data
36 self.obj_href = obj_href
36 self.obj_href = obj_href
37 self.obj_verify_href = obj_verify_href
37 self.obj_verify_href = obj_verify_href
38
38
39 def get_store(self, mode=None):
39 def get_store(self, mode=None):
40 return self.current_store
40 return self.current_store
41
41
42 def get_auth(self):
42 def get_auth(self):
43 """returns auth header for re-use in upload/download"""
43 """returns auth header for re-use in upload/download"""
44 return " ".join(self.auth)
44 return " ".join(self.auth)
45
45
46 def download(self):
46 def download(self):
47
47
48 store = self.get_store()
48 store = self.get_store()
49 response = None
49 response = None
50 has_errors = None
50 has_errors = None
51
51
52 if not store.has_oid():
52 if not store.has_oid():
53 # error reply back to client that something is wrong with dl
53 # error reply back to client that something is wrong with dl
54 err_msg = f'object: {store.oid} does not exist in store'
54 err_msg = f'object: {store.oid} does not exist in store'
55 has_errors = OrderedDict(
55 has_errors = OrderedDict(
56 error=OrderedDict(
56 error=OrderedDict(
57 code=404,
57 code=404,
58 message=err_msg
58 message=err_msg
59 )
59 )
60 )
60 )
61
61
62 download_action = OrderedDict(
62 download_action = OrderedDict(
63 href=self.obj_href,
63 href=self.obj_href,
64 header=OrderedDict([("Authorization", self.get_auth())])
64 header=OrderedDict([("Authorization", self.get_auth())])
65 )
65 )
66 if not has_errors:
66 if not has_errors:
67 response = OrderedDict(download=download_action)
67 response = OrderedDict(download=download_action)
68 return response, has_errors
68 return response, has_errors
69
69
70 def upload(self, skip_existing=True):
70 def upload(self, skip_existing=True):
71 """
71 """
72 Write upload action for git-lfs server
72 Write upload action for git-lfs server
73 """
73 """
74
74
75 store = self.get_store()
75 store = self.get_store()
76 response = None
76 response = None
77 has_errors = None
77 has_errors = None
78
78
79 # verify if we have the OID before, if we do, reply with empty
79 # verify if we have the OID before, if we do, reply with empty
80 if store.has_oid():
80 if store.has_oid():
81 log.debug('LFS: store already has oid %s', store.oid)
81 log.debug('LFS: store already has oid %s', store.oid)
82
82
83 # validate size
83 # validate size
84 store_size = store.size_oid()
84 store_size = store.size_oid()
85 size_match = store_size == self.obj_size
85 size_match = store_size == self.obj_size
86 if not size_match:
86 if not size_match:
87 log.warning(
87 log.warning(
88 'LFS: size mismatch for oid:%s, in store:%s expected: %s',
88 'LFS: size mismatch for oid:%s, in store:%s expected: %s',
89 self.oid, store_size, self.obj_size)
89 self.oid, store_size, self.obj_size)
90 elif skip_existing:
90 elif skip_existing:
91 log.debug('LFS: skipping further action as oid is existing')
91 log.debug('LFS: skipping further action as oid is existing')
92 return response, has_errors
92 return response, has_errors
93
93
94 chunked = ("Transfer-Encoding", "chunked")
94 chunked = ("Transfer-Encoding", "chunked")
95 upload_action = OrderedDict(
95 upload_action = OrderedDict(
96 href=self.obj_href,
96 href=self.obj_href,
97 header=OrderedDict([("Authorization", self.get_auth()), chunked])
97 header=OrderedDict([("Authorization", self.get_auth()), chunked])
98 )
98 )
99 if not has_errors:
99 if not has_errors:
100 response = OrderedDict(upload=upload_action)
100 response = OrderedDict(upload=upload_action)
101 # if specified in handler, return the verification endpoint
101 # if specified in handler, return the verification endpoint
102 if self.obj_verify_href:
102 if self.obj_verify_href:
103 verify_action = OrderedDict(
103 verify_action = OrderedDict(
104 href=self.obj_verify_href,
104 href=self.obj_verify_href,
105 header=OrderedDict([("Authorization", self.get_auth())])
105 header=OrderedDict([("Authorization", self.get_auth())])
106 )
106 )
107 response['verify'] = verify_action
107 response['verify'] = verify_action
108 return response, has_errors
108 return response, has_errors
109
109
110 def exec_operation(self, operation, *args, **kwargs):
110 def exec_operation(self, operation, *args, **kwargs):
111 handler = getattr(self, operation)
111 handler = getattr(self, operation)
112 log.debug('LFS: handling request using %s handler', handler)
112 log.debug('LFS: handling request using %s handler', handler)
113 return handler(*args, **kwargs)
113 return handler(*args, **kwargs)
114
114
115
115
116 class LFSOidStore:
116 class LFSOidStore:
117
117
118 def __init__(self, oid, repo, store_location=None):
118 def __init__(self, oid, repo, store_location=None):
119 self.oid = oid
119 self.oid = oid
120 self.repo = repo
120 self.repo = repo
121 defined_store_path = store_location or self.get_default_store()
121 defined_store_path = store_location or self.get_default_store()
122 self.store_suffix = f"/objects/{oid[:2]}/{oid[2:4]}"
122 self.store_suffix = f"/objects/{oid[:2]}/{oid[2:4]}"
123 self.store_path = f"{defined_store_path.rstrip('/')}{self.store_suffix}"
123 self.store_path = f"{defined_store_path.rstrip('/')}{self.store_suffix}"
124 self.tmp_oid_path = os.path.join(self.store_path, oid + '.tmp')
124 self.tmp_oid_path = os.path.join(self.store_path, oid + '.tmp')
125 self.oid_path = os.path.join(self.store_path, oid)
125 self.oid_path = os.path.join(self.store_path, oid)
126 self.fd = None
126 self.fd = None
127
127
128 def get_engine(self, mode):
128 def get_engine(self, mode):
129 """
129 """
130 engine = .get_engine(mode='wb')
130 engine = .get_engine(mode='wb')
131 with engine as f:
131 with engine as f:
132 f.write('...')
132 f.write('...')
133 """
133 """
134
134
135 class StoreEngine:
135 class StoreEngine:
136 _cleanup = None
136 def __init__(self, mode, store_path, oid_path, tmp_oid_path):
137 def __init__(self, mode, store_path, oid_path, tmp_oid_path):
137 self.mode = mode
138 self.mode = mode
138 self.store_path = store_path
139 self.store_path = store_path
139 self.oid_path = oid_path
140 self.oid_path = oid_path
140 self.tmp_oid_path = tmp_oid_path
141 self.tmp_oid_path = tmp_oid_path
141
142
143 def cleanup(self):
144 self._cleanup = True
145
142 def __enter__(self):
146 def __enter__(self):
143 if not os.path.isdir(self.store_path):
147 if not os.path.isdir(self.store_path):
144 os.makedirs(self.store_path)
148 os.makedirs(self.store_path)
145
149
146 # TODO(marcink): maybe write metadata here with size/oid ?
150 # TODO(marcink): maybe write metadata here with size/oid ?
147 fd = open(self.tmp_oid_path, self.mode)
151 fd = open(self.tmp_oid_path, self.mode)
148 self.fd = fd
152 self.fd = fd
149 return fd
153 return fd
150
154
151 def __exit__(self, exc_type, exc_value, traceback):
155 def __exit__(self, exc_type, exc_value, traceback):
152 # close tmp file, and rename to final destination
153 self.fd.close()
156 self.fd.close()
154 shutil.move(self.tmp_oid_path, self.oid_path)
157
158 if self._cleanup is None:
159 # close tmp file, and rename to final destination
160 shutil.move(self.tmp_oid_path, self.oid_path)
161 else:
162 os.remove(self.tmp_oid_path)
155
163
156 return StoreEngine(
164 return StoreEngine(
157 mode, self.store_path, self.oid_path, self.tmp_oid_path)
165 mode, self.store_path, self.oid_path, self.tmp_oid_path)
158
166
159 def get_default_store(self):
167 def get_default_store(self):
160 """
168 """
161 Default store, consistent with defaults of Mercurial large files store
169 Default store, consistent with defaults of Mercurial large files store
162 which is /home/username/.cache/largefiles
170 which is /home/username/.cache/largefiles
163 """
171 """
164 user_home = os.path.expanduser("~")
172 user_home = os.path.expanduser("~")
165 return os.path.join(user_home, '.cache', 'lfs-store')
173 return os.path.join(user_home, '.cache', 'lfs-store')
166
174
167 def has_oid(self):
175 def has_oid(self):
168 return os.path.exists(os.path.join(self.store_path, self.oid))
176 return os.path.exists(os.path.join(self.store_path, self.oid))
169
177
170 def size_oid(self):
178 def size_oid(self):
171 size = -1
179 size = -1
172
180
173 if self.has_oid():
181 if self.has_oid():
174 oid = os.path.join(self.store_path, self.oid)
182 oid = os.path.join(self.store_path, self.oid)
175 size = os.stat(oid).st_size
183 size = os.stat(oid).st_size
176
184
177 return size
185 return size
@@ -1,274 +1,310 b''
1 # RhodeCode VCSServer provides access to different vcs backends via network.
1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 # Copyright (C) 2014-2023 RhodeCode GmbH
2 # Copyright (C) 2014-2023 RhodeCode GmbH
3 #
3 #
4 # This program is free software; you can redistribute it and/or modify
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
7 # (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
18 import os
18 import os
19 import pytest
19 import pytest
20 from webtest.app import TestApp as WebObTestApp
20 from webtest.app import TestApp as WebObTestApp
21
21
22 from vcsserver.lib.ext_json import json
22 from vcsserver.lib.ext_json import json
23 from vcsserver.lib.str_utils import safe_bytes
23 from vcsserver.lib.str_utils import safe_bytes
24 from vcsserver.git_lfs.app import create_app
24 from vcsserver.git_lfs.app import create_app
25 from vcsserver.git_lfs.lib import LFSOidStore
25 from vcsserver.git_lfs.lib import LFSOidStore
26
26
27
27
28 @pytest.fixture(scope='function')
28 @pytest.fixture(scope='function')
29 def git_lfs_app(tmpdir):
29 def git_lfs_app(tmpdir):
30 custom_app = WebObTestApp(create_app(
30 custom_app = WebObTestApp(create_app(
31 git_lfs_enabled=True, git_lfs_store_path=str(tmpdir),
31 git_lfs_enabled=True, git_lfs_store_path=str(tmpdir),
32 git_lfs_http_scheme='http'))
32 git_lfs_http_scheme='http'))
33 custom_app._store = str(tmpdir)
33 custom_app._store = str(tmpdir)
34 return custom_app
34 return custom_app
35
35
36
36
37 @pytest.fixture(scope='function')
37 @pytest.fixture(scope='function')
38 def git_lfs_https_app(tmpdir):
38 def git_lfs_https_app(tmpdir):
39 custom_app = WebObTestApp(create_app(
39 custom_app = WebObTestApp(create_app(
40 git_lfs_enabled=True, git_lfs_store_path=str(tmpdir),
40 git_lfs_enabled=True, git_lfs_store_path=str(tmpdir),
41 git_lfs_http_scheme='https'))
41 git_lfs_http_scheme='https'))
42 custom_app._store = str(tmpdir)
42 custom_app._store = str(tmpdir)
43 return custom_app
43 return custom_app
44
44
45
45
46 @pytest.fixture()
46 @pytest.fixture()
47 def http_auth():
47 def http_auth():
48 return {'HTTP_AUTHORIZATION': "Basic XXXXX"}
48 return {'HTTP_AUTHORIZATION': "Basic XXXXX"}
49
49
50
50
51 class TestLFSApplication:
51 class TestLFSApplication:
52
52
53 def test_app_wrong_path(self, git_lfs_app):
53 def test_app_wrong_path(self, git_lfs_app):
54 git_lfs_app.get('/repo/info/lfs/xxx', status=404)
54 git_lfs_app.get('/repo/info/lfs/xxx', status=404)
55
55
56 def test_app_deprecated_endpoint(self, git_lfs_app):
56 def test_app_deprecated_endpoint(self, git_lfs_app):
57 response = git_lfs_app.post('/repo/info/lfs/objects', status=501)
57 response = git_lfs_app.post('/repo/info/lfs/objects', status=501)
58 assert response.status_code == 501
58 assert response.status_code == 501
59 assert json.loads(response.text) == {'message': 'LFS: v1 api not supported'}
59 assert json.loads(response.text) == {'message': 'LFS: v1 api not supported'}
60
60
61 def test_app_lock_verify_api_not_available(self, git_lfs_app):
61 def test_app_lock_verify_api_not_available(self, git_lfs_app):
62 response = git_lfs_app.post('/repo/info/lfs/locks/verify', status=501)
62 response = git_lfs_app.post('/repo/info/lfs/locks/verify', status=501)
63 assert response.status_code == 501
63 assert response.status_code == 501
64 assert json.loads(response.text) == {
64 assert json.loads(response.text) == {
65 'message': 'GIT LFS locking api not supported'}
65 'message': 'GIT LFS locking api not supported'}
66
66
67 def test_app_lock_api_not_available(self, git_lfs_app):
67 def test_app_lock_api_not_available(self, git_lfs_app):
68 response = git_lfs_app.post('/repo/info/lfs/locks', status=501)
68 response = git_lfs_app.post('/repo/info/lfs/locks', status=501)
69 assert response.status_code == 501
69 assert response.status_code == 501
70 assert json.loads(response.text) == {
70 assert json.loads(response.text) == {
71 'message': 'GIT LFS locking api not supported'}
71 'message': 'GIT LFS locking api not supported'}
72
72
73 def test_app_batch_api_missing_auth(self, git_lfs_app):
73 def test_app_batch_api_missing_auth(self, git_lfs_app):
74 git_lfs_app.post_json(
74 git_lfs_app.post_json(
75 '/repo/info/lfs/objects/batch', params={}, status=403)
75 '/repo/info/lfs/objects/batch', params={}, status=403)
76
76
77 def test_app_batch_api_unsupported_operation(self, git_lfs_app, http_auth):
77 def test_app_batch_api_unsupported_operation(self, git_lfs_app, http_auth):
78 response = git_lfs_app.post_json(
78 response = git_lfs_app.post_json(
79 '/repo/info/lfs/objects/batch', params={}, status=400,
79 '/repo/info/lfs/objects/batch', params={}, status=400,
80 extra_environ=http_auth)
80 extra_environ=http_auth)
81 assert json.loads(response.text) == {
81 assert json.loads(response.text) == {
82 'message': 'unsupported operation mode: `None`'}
82 'message': 'unsupported operation mode: `None`'}
83
83
84 def test_app_batch_api_missing_objects(self, git_lfs_app, http_auth):
84 def test_app_batch_api_missing_objects(self, git_lfs_app, http_auth):
85 response = git_lfs_app.post_json(
85 response = git_lfs_app.post_json(
86 '/repo/info/lfs/objects/batch', params={'operation': 'download'},
86 '/repo/info/lfs/objects/batch', params={'operation': 'download'},
87 status=400, extra_environ=http_auth)
87 status=400, extra_environ=http_auth)
88 assert json.loads(response.text) == {
88 assert json.loads(response.text) == {
89 'message': 'missing objects data'}
89 'message': 'missing objects data'}
90
90
91 def test_app_batch_api_unsupported_data_in_objects(
91 def test_app_batch_api_unsupported_data_in_objects(
92 self, git_lfs_app, http_auth):
92 self, git_lfs_app, http_auth):
93 params = {'operation': 'download',
93 params = {'operation': 'download',
94 'objects': [{}]}
94 'objects': [{}]}
95 response = git_lfs_app.post_json(
95 response = git_lfs_app.post_json(
96 '/repo/info/lfs/objects/batch', params=params, status=400,
96 '/repo/info/lfs/objects/batch', params=params, status=400,
97 extra_environ=http_auth)
97 extra_environ=http_auth)
98 assert json.loads(response.text) == {
98 assert json.loads(response.text) == {
99 'message': 'unsupported data in objects'}
99 'message': 'unsupported data in objects'}
100
100
101 def test_app_batch_api_download_missing_object(
101 def test_app_batch_api_download_missing_object(
102 self, git_lfs_app, http_auth):
102 self, git_lfs_app, http_auth):
103 params = {'operation': 'download',
103 params = {
104 'objects': [{'oid': '123', 'size': '1024'}]}
104 'operation': 'download',
105 'objects': [{'oid': '123', 'size': '1024'}]
106 }
105 response = git_lfs_app.post_json(
107 response = git_lfs_app.post_json(
106 '/repo/info/lfs/objects/batch', params=params,
108 '/repo/info/lfs/objects/batch', params=params,
107 extra_environ=http_auth)
109 extra_environ=http_auth)
108
110
109 expected_objects = [
111 expected_objects = [
110 {'authenticated': True,
112 {
111 'errors': {'error': {
113 'oid': '123',
112 'code': 404,
114 'size': '1024',
113 'message': 'object: 123 does not exist in store'}},
115 'authenticated': True,
114 'oid': '123',
116 'errors': {'error': {'code': 404, 'message': 'object: 123 does not exist in store'}},
115 'size': '1024'}
117 }
116 ]
118 ]
119
117 assert json.loads(response.text) == {
120 assert json.loads(response.text) == {
118 'objects': expected_objects, 'transfer': 'basic'}
121 'objects': expected_objects,
122 'transfer': 'basic'
123 }
119
124
120 def test_app_batch_api_download(self, git_lfs_app, http_auth):
125 def test_app_batch_api_download(self, git_lfs_app, http_auth):
121 oid = '456'
126 oid = '456'
122 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
127 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
123 if not os.path.isdir(os.path.dirname(oid_path)):
128 if not os.path.isdir(os.path.dirname(oid_path)):
124 os.makedirs(os.path.dirname(oid_path))
129 os.makedirs(os.path.dirname(oid_path))
125 with open(oid_path, 'wb') as f:
130 with open(oid_path, 'wb') as f:
126 f.write(safe_bytes('OID_CONTENT'))
131 f.write(safe_bytes('OID_CONTENT'))
127
132
128 params = {'operation': 'download',
133 params = {'operation': 'download',
129 'objects': [{'oid': oid, 'size': '1024'}]}
134 'objects': [{'oid': oid, 'size': '1024'}]}
130 response = git_lfs_app.post_json(
135 response = git_lfs_app.post_json(
131 '/repo/info/lfs/objects/batch', params=params,
136 '/repo/info/lfs/objects/batch', params=params,
132 extra_environ=http_auth)
137 extra_environ=http_auth)
133
138
134 expected_objects = [
139 expected_objects = [
135 {'authenticated': True,
140 {'authenticated': True,
136 'actions': {
141 'actions': {
137 'download': {
142 'download': {
138 'header': {'Authorization': 'Basic XXXXX'},
143 'header': {'Authorization': 'Basic XXXXX'},
139 'href': 'http://localhost/repo/info/lfs/objects/456'},
144 'href': 'http://localhost/repo/info/lfs/objects/456'},
140 },
145 },
141 'oid': '456',
146 'oid': '456',
142 'size': '1024'}
147 'size': '1024'}
143 ]
148 ]
144 assert json.loads(response.text) == {
149 assert json.loads(response.text) == {
145 'objects': expected_objects, 'transfer': 'basic'}
150 'objects': expected_objects,
151 'transfer': 'basic'
152 }
146
153
147 def test_app_batch_api_upload(self, git_lfs_app, http_auth):
154 def test_app_batch_api_upload(self, git_lfs_app, http_auth):
148 params = {'operation': 'upload',
155 params = {'operation': 'upload',
149 'objects': [{'oid': '123', 'size': '1024'}]}
156 'objects': [{'oid': '123', 'size': '1024'}]}
150 response = git_lfs_app.post_json(
157 response = git_lfs_app.post_json(
151 '/repo/info/lfs/objects/batch', params=params,
158 '/repo/info/lfs/objects/batch', params=params,
152 extra_environ=http_auth)
159 extra_environ=http_auth)
153 expected_objects = [
160 expected_objects = [
154 {'authenticated': True,
161 {
155 'actions': {
162 'authenticated': True,
156 'upload': {
163 'actions': {
157 'header': {'Authorization': 'Basic XXXXX',
164 'upload': {
158 'Transfer-Encoding': 'chunked'},
165 'header': {
159 'href': 'http://localhost/repo/info/lfs/objects/123'},
166 'Authorization': 'Basic XXXXX',
160 'verify': {
167 'Transfer-Encoding': 'chunked'
161 'header': {'Authorization': 'Basic XXXXX'},
168 },
162 'href': 'http://localhost/repo/info/lfs/verify'}
169 'href': 'http://localhost/repo/info/lfs/objects/123'
163 },
170 },
164 'oid': '123',
171 'verify': {
165 'size': '1024'}
172 'header': {
173 'Authorization': 'Basic XXXXX'
174 },
175 'href': 'http://localhost/repo/info/lfs/verify'
176 }
177 },
178 'oid': '123',
179 'size': '1024'
180 }
166 ]
181 ]
167 assert json.loads(response.text) == {
182 assert json.loads(response.text) == {
168 'objects': expected_objects, 'transfer': 'basic'}
183 'objects': expected_objects,
184 'transfer': 'basic'
185 }
169
186
170 def test_app_batch_api_upload_for_https(self, git_lfs_https_app, http_auth):
187 def test_app_batch_api_upload_for_https(self, git_lfs_https_app, http_auth):
171 params = {'operation': 'upload',
188 params = {'operation': 'upload',
172 'objects': [{'oid': '123', 'size': '1024'}]}
189 'objects': [{'oid': '123', 'size': '1024'}]}
173 response = git_lfs_https_app.post_json(
190 response = git_lfs_https_app.post_json(
174 '/repo/info/lfs/objects/batch', params=params,
191 '/repo/info/lfs/objects/batch', params=params,
175 extra_environ=http_auth)
192 extra_environ=http_auth)
176 expected_objects = [
193 expected_objects = [
177 {'authenticated': True,
194 {'authenticated': True,
178 'actions': {
195 'actions': {
179 'upload': {
196 'upload': {
180 'header': {'Authorization': 'Basic XXXXX',
197 'header': {'Authorization': 'Basic XXXXX',
181 'Transfer-Encoding': 'chunked'},
198 'Transfer-Encoding': 'chunked'},
182 'href': 'https://localhost/repo/info/lfs/objects/123'},
199 'href': 'https://localhost/repo/info/lfs/objects/123'},
183 'verify': {
200 'verify': {
184 'header': {'Authorization': 'Basic XXXXX'},
201 'header': {'Authorization': 'Basic XXXXX'},
185 'href': 'https://localhost/repo/info/lfs/verify'}
202 'href': 'https://localhost/repo/info/lfs/verify'}
186 },
203 },
187 'oid': '123',
204 'oid': '123',
188 'size': '1024'}
205 'size': '1024'}
189 ]
206 ]
190 assert json.loads(response.text) == {
207 assert json.loads(response.text) == {
191 'objects': expected_objects, 'transfer': 'basic'}
208 'objects': expected_objects, 'transfer': 'basic'}
192
209
193 def test_app_verify_api_missing_data(self, git_lfs_app):
210 def test_app_verify_api_missing_data(self, git_lfs_app):
194 params = {'oid': 'missing'}
211 params = {'oid': 'missing'}
195 response = git_lfs_app.post_json(
212 response = git_lfs_app.post_json(
196 '/repo/info/lfs/verify', params=params,
213 '/repo/info/lfs/verify', params=params,
197 status=400)
214 status=400)
198
215
199 assert json.loads(response.text) == {
216 assert json.loads(response.text) == {
200 'message': 'missing oid and size in request data'}
217 'message': 'missing oid and size in request data'}
201
218
202 def test_app_verify_api_missing_obj(self, git_lfs_app):
219 def test_app_verify_api_missing_obj(self, git_lfs_app):
203 params = {'oid': 'missing', 'size': '1024'}
220 params = {'oid': 'missing', 'size': '1024'}
204 response = git_lfs_app.post_json(
221 response = git_lfs_app.post_json(
205 '/repo/info/lfs/verify', params=params,
222 '/repo/info/lfs/verify', params=params,
206 status=404)
223 status=404)
207
224
208 assert json.loads(response.text) == {
225 assert json.loads(response.text) == {
209 'message': 'oid `missing` does not exists in store'}
226 'message': 'oid `missing` does not exists in store'
227 }
210
228
211 def test_app_verify_api_size_mismatch(self, git_lfs_app):
229 def test_app_verify_api_size_mismatch(self, git_lfs_app):
212 oid = 'existing'
230 oid = 'existing'
213 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
231 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
214 if not os.path.isdir(os.path.dirname(oid_path)):
232 if not os.path.isdir(os.path.dirname(oid_path)):
215 os.makedirs(os.path.dirname(oid_path))
233 os.makedirs(os.path.dirname(oid_path))
216 with open(oid_path, 'wb') as f:
234 with open(oid_path, 'wb') as f:
217 f.write(safe_bytes('OID_CONTENT'))
235 f.write(safe_bytes('OID_CONTENT'))
218
236
219 params = {'oid': oid, 'size': '1024'}
237 params = {'oid': oid, 'size': '1024'}
220 response = git_lfs_app.post_json(
238 response = git_lfs_app.post_json(
221 '/repo/info/lfs/verify', params=params, status=422)
239 '/repo/info/lfs/verify', params=params, status=422)
222
240
223 assert json.loads(response.text) == {
241 assert json.loads(response.text) == {
224 'message': 'requested file size mismatch '
242 'message': 'requested file size mismatch store size:11 requested:1024'
225 'store size:11 requested:1024'}
243 }
226
244
227 def test_app_verify_api(self, git_lfs_app):
245 def test_app_verify_api(self, git_lfs_app):
228 oid = 'existing'
246 oid = 'existing'
229 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
247 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
230 if not os.path.isdir(os.path.dirname(oid_path)):
248 if not os.path.isdir(os.path.dirname(oid_path)):
231 os.makedirs(os.path.dirname(oid_path))
249 os.makedirs(os.path.dirname(oid_path))
232 with open(oid_path, 'wb') as f:
250 with open(oid_path, 'wb') as f:
233 f.write(safe_bytes('OID_CONTENT'))
251 f.write(safe_bytes('OID_CONTENT'))
234
252
235 params = {'oid': oid, 'size': 11}
253 params = {'oid': oid, 'size': 11}
236 response = git_lfs_app.post_json(
254 response = git_lfs_app.post_json(
237 '/repo/info/lfs/verify', params=params)
255 '/repo/info/lfs/verify', params=params)
238
256
239 assert json.loads(response.text) == {
257 assert json.loads(response.text) == {
240 'message': {'size': 'ok', 'in_store': 'ok'}}
258 'message': {'size': 11, 'oid': oid}
259 }
241
260
242 def test_app_download_api_oid_not_existing(self, git_lfs_app):
261 def test_app_download_api_oid_not_existing(self, git_lfs_app):
243 oid = 'missing'
262 oid = 'missing'
244
263
245 response = git_lfs_app.get(
264 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}', status=404)
246 '/repo/info/lfs/objects/{oid}'.format(oid=oid), status=404)
247
265
248 assert json.loads(response.text) == {
266 assert json.loads(response.text) == {
249 'message': 'requested file with oid `missing` not found in store'}
267 'message': 'requested file with oid `missing` not found in store'}
250
268
251 def test_app_download_api(self, git_lfs_app):
269 def test_app_download_api(self, git_lfs_app):
252 oid = 'existing'
270 oid = 'existing'
253 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
271 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
254 if not os.path.isdir(os.path.dirname(oid_path)):
272 if not os.path.isdir(os.path.dirname(oid_path)):
255 os.makedirs(os.path.dirname(oid_path))
273 os.makedirs(os.path.dirname(oid_path))
256 with open(oid_path, 'wb') as f:
274 with open(oid_path, 'wb') as f:
257 f.write(safe_bytes('OID_CONTENT'))
275 f.write(safe_bytes('OID_CONTENT'))
258
276
259 response = git_lfs_app.get(
277 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}')
260 '/repo/info/lfs/objects/{oid}'.format(oid=oid))
261 assert response
278 assert response
262
279
263 def test_app_upload(self, git_lfs_app):
280 def test_app_upload(self, git_lfs_app):
264 oid = 'uploaded'
281 oid = '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'
265
282
266 response = git_lfs_app.put(
283 response = git_lfs_app.put(
267 '/repo/info/lfs/objects/{oid}'.format(oid=oid), params='CONTENT')
284 f'/repo/info/lfs/objects/{oid}', params='CONTENT')
268
285
269 assert json.loads(response.text) == {'upload': 'ok'}
286 assert json.loads(response.text) == {'upload': 'ok', 'state': 'written'}
270
287
271 # verify that we actually wrote that OID
288 # verify that we actually wrote that OID
272 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
289 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
273 assert os.path.isfile(oid_path)
290 assert os.path.isfile(oid_path)
274 assert 'CONTENT' == open(oid_path).read()
291 assert 'CONTENT' == open(oid_path).read()
292
293 response = git_lfs_app.put(
294 f'/repo/info/lfs/objects/{oid}', params='CONTENT')
295
296 assert json.loads(response.text) == {'upload': 'ok', 'state': 'in-store'}
297
298
299 def test_app_upload_wrong_sha(self, git_lfs_app):
300 oid = 'i-am-a-wrong-sha'
301
302 response = git_lfs_app.put(f'/repo/info/lfs/objects/{oid}', params='CONTENT', status=400)
303
304 assert json.loads(response.text) == {
305 'message': 'oid i-am-a-wrong-sha does not match expected sha '
306 '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'}
307
308 # check this OID wasn't written to store
309 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}', status=404)
310 assert json.loads(response.text) == {'message': 'requested file with oid `i-am-a-wrong-sha` not found in store'}
@@ -1,142 +1,143 b''
1 # RhodeCode VCSServer provides access to different vcs backends via network.
1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 # Copyright (C) 2014-2023 RhodeCode GmbH
2 # Copyright (C) 2014-2023 RhodeCode GmbH
3 #
3 #
4 # This program is free software; you can redistribute it and/or modify
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
7 # (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
18 import os
18 import os
19 import pytest
19 import pytest
20 from vcsserver.lib.str_utils import safe_bytes
20 from vcsserver.lib.str_utils import safe_bytes
21 from vcsserver.git_lfs.lib import OidHandler, LFSOidStore
21 from vcsserver.git_lfs.lib import OidHandler, LFSOidStore
22
22
23
23
24 @pytest.fixture()
24 @pytest.fixture()
25 def lfs_store(tmpdir):
25 def lfs_store(tmpdir):
26 repo = 'test'
26 repo = 'test'
27 oid = '123456789'
27 oid = '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'
28 store = LFSOidStore(oid=oid, repo=repo, store_location=str(tmpdir))
28 store = LFSOidStore(oid=oid, repo=repo, store_location=str(tmpdir))
29 return store
29 return store
30
30
31
31
32 @pytest.fixture()
32 @pytest.fixture()
33 def oid_handler(lfs_store):
33 def oid_handler(lfs_store):
34 store = lfs_store
34 store = lfs_store
35 repo = store.repo
35 repo = store.repo
36 oid = store.oid
36 oid = store.oid
37
37
38 oid_handler = OidHandler(
38 oid_handler = OidHandler(
39 store=store, repo_name=repo, auth=('basic', 'xxxx'),
39 store=store, repo_name=repo, auth=('basic', 'xxxx'),
40 oid=oid,
40 oid=oid,
41 obj_size='1024', obj_data={}, obj_href='http://localhost/handle_oid',
41 obj_size='1024', obj_data={}, obj_href='http://localhost/handle_oid',
42 obj_verify_href='http://localhost/verify')
42 obj_verify_href='http://localhost/verify')
43 return oid_handler
43 return oid_handler
44
44
45
45
46 class TestOidHandler:
46 class TestOidHandler:
47
47
48 @pytest.mark.parametrize('exec_action', [
48 @pytest.mark.parametrize('exec_action', [
49 'download',
49 'download',
50 'upload',
50 'upload',
51 ])
51 ])
52 def test_exec_action(self, exec_action, oid_handler):
52 def test_exec_action(self, exec_action, oid_handler):
53 handler = oid_handler.exec_operation(exec_action)
53 handler = oid_handler.exec_operation(exec_action)
54 assert handler
54 assert handler
55
55
56 def test_exec_action_undefined(self, oid_handler):
56 def test_exec_action_undefined(self, oid_handler):
57 with pytest.raises(AttributeError):
57 with pytest.raises(AttributeError):
58 oid_handler.exec_operation('wrong')
58 oid_handler.exec_operation('wrong')
59
59
60 def test_download_oid_not_existing(self, oid_handler):
60 def test_download_oid_not_existing(self, oid_handler):
61 response, has_errors = oid_handler.exec_operation('download')
61 response, has_errors = oid_handler.exec_operation('download')
62
62
63 assert response is None
63 assert response is None
64 assert has_errors['error'] == {
64 assert has_errors['error'] == {
65 'code': 404,
65 'code': 404,
66 'message': 'object: 123456789 does not exist in store'}
66 'message': 'object: 65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12 does not exist in store'
67 }
67
68
68 def test_download_oid(self, oid_handler):
69 def test_download_oid(self, oid_handler):
69 store = oid_handler.get_store()
70 store = oid_handler.get_store()
70 if not os.path.isdir(os.path.dirname(store.oid_path)):
71 if not os.path.isdir(os.path.dirname(store.oid_path)):
71 os.makedirs(os.path.dirname(store.oid_path))
72 os.makedirs(os.path.dirname(store.oid_path))
72
73
73 with open(store.oid_path, 'wb') as f:
74 with open(store.oid_path, 'wb') as f:
74 f.write(safe_bytes('CONTENT'))
75 f.write(safe_bytes('CONTENT'))
75
76
76 response, has_errors = oid_handler.exec_operation('download')
77 response, has_errors = oid_handler.exec_operation('download')
77
78
78 assert has_errors is None
79 assert has_errors is None
79 assert response['download'] == {
80 assert response['download'] == {
80 'header': {'Authorization': 'basic xxxx'},
81 'header': {'Authorization': 'basic xxxx'},
81 'href': 'http://localhost/handle_oid'
82 'href': 'http://localhost/handle_oid'
82 }
83 }
83
84
84 def test_upload_oid_that_exists(self, oid_handler):
85 def test_upload_oid_that_exists(self, oid_handler):
85 store = oid_handler.get_store()
86 store = oid_handler.get_store()
86 if not os.path.isdir(os.path.dirname(store.oid_path)):
87 if not os.path.isdir(os.path.dirname(store.oid_path)):
87 os.makedirs(os.path.dirname(store.oid_path))
88 os.makedirs(os.path.dirname(store.oid_path))
88
89
89 with open(store.oid_path, 'wb') as f:
90 with open(store.oid_path, 'wb') as f:
90 f.write(safe_bytes('CONTENT'))
91 f.write(safe_bytes('CONTENT'))
91 oid_handler.obj_size = 7
92 oid_handler.obj_size = 7
92 response, has_errors = oid_handler.exec_operation('upload')
93 response, has_errors = oid_handler.exec_operation('upload')
93 assert has_errors is None
94 assert has_errors is None
94 assert response is None
95 assert response is None
95
96
96 def test_upload_oid_that_exists_but_has_wrong_size(self, oid_handler):
97 def test_upload_oid_that_exists_but_has_wrong_size(self, oid_handler):
97 store = oid_handler.get_store()
98 store = oid_handler.get_store()
98 if not os.path.isdir(os.path.dirname(store.oid_path)):
99 if not os.path.isdir(os.path.dirname(store.oid_path)):
99 os.makedirs(os.path.dirname(store.oid_path))
100 os.makedirs(os.path.dirname(store.oid_path))
100
101
101 with open(store.oid_path, 'wb') as f:
102 with open(store.oid_path, 'wb') as f:
102 f.write(safe_bytes('CONTENT'))
103 f.write(safe_bytes('CONTENT'))
103
104
104 oid_handler.obj_size = 10240
105 oid_handler.obj_size = 10240
105 response, has_errors = oid_handler.exec_operation('upload')
106 response, has_errors = oid_handler.exec_operation('upload')
106 assert has_errors is None
107 assert has_errors is None
107 assert response['upload'] == {
108 assert response['upload'] == {
108 'header': {'Authorization': 'basic xxxx',
109 'header': {'Authorization': 'basic xxxx',
109 'Transfer-Encoding': 'chunked'},
110 'Transfer-Encoding': 'chunked'},
110 'href': 'http://localhost/handle_oid',
111 'href': 'http://localhost/handle_oid',
111 }
112 }
112
113
113 def test_upload_oid(self, oid_handler):
114 def test_upload_oid(self, oid_handler):
114 response, has_errors = oid_handler.exec_operation('upload')
115 response, has_errors = oid_handler.exec_operation('upload')
115 assert has_errors is None
116 assert has_errors is None
116 assert response['upload'] == {
117 assert response['upload'] == {
117 'header': {'Authorization': 'basic xxxx',
118 'header': {'Authorization': 'basic xxxx',
118 'Transfer-Encoding': 'chunked'},
119 'Transfer-Encoding': 'chunked'},
119 'href': 'http://localhost/handle_oid'
120 'href': 'http://localhost/handle_oid'
120 }
121 }
121
122
122
123
123 class TestLFSStore:
124 class TestLFSStore:
124 def test_write_oid(self, lfs_store):
125 def test_write_oid(self, lfs_store):
125 oid_location = lfs_store.oid_path
126 oid_location = lfs_store.oid_path
126
127
127 assert not os.path.isfile(oid_location)
128 assert not os.path.isfile(oid_location)
128
129
129 engine = lfs_store.get_engine(mode='wb')
130 engine = lfs_store.get_engine(mode='wb')
130 with engine as f:
131 with engine as f:
131 f.write(safe_bytes('CONTENT'))
132 f.write(safe_bytes('CONTENT'))
132
133
133 assert os.path.isfile(oid_location)
134 assert os.path.isfile(oid_location)
134
135
135 def test_detect_has_oid(self, lfs_store):
136 def test_detect_has_oid(self, lfs_store):
136
137
137 assert lfs_store.has_oid() is False
138 assert lfs_store.has_oid() is False
138 engine = lfs_store.get_engine(mode='wb')
139 engine = lfs_store.get_engine(mode='wb')
139 with engine as f:
140 with engine as f:
140 f.write(safe_bytes('CONTENT'))
141 f.write(safe_bytes('CONTENT'))
141
142
142 assert lfs_store.has_oid() is True
143 assert lfs_store.has_oid() is True
General Comments 0
You need to be logged in to leave comments. Login now