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