msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,769 @@
1
+ """Native public Git repositories, physically separate from internal topic Git."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import base64
5
+ from contextvars import ContextVar
6
+ from dataclasses import replace
7
+ from datetime import timedelta
8
+ import hashlib
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ from uuid import uuid4
17
+ from urllib.parse import urlsplit
18
+
19
+ from starlette.requests import ClientDisconnect
20
+ from starlette.responses import Response,StreamingResponse
21
+ from msg.core.codec import canonical,decode,wire,unb64,b64
22
+ from msg.core.errors import Failure,require
23
+ from msg.core.models import HandlerOutput,ResourceRef,EffectJob,ExecutionContext
24
+ from msg.core.requests import request_for
25
+ from msg.plugins.common import check_access,create_resource,resolve,output_for,new_id,operation_id
26
+ from msg.plugins.communication import event_id
27
+ from msg.plugins.schemas import obj,STRING,IDENTIFIER,REF,BOOLEAN
28
+ from msg.storage.git import durable_write,LFSObjectStore
29
+
30
+ OID={'type':['string','null'],'pattern':'^[0-9a-f]{40}$'}
31
+ CHANGE=obj({'ref':{'type':'string','pattern':'^refs/(heads|tags)/[^\\s]+$'},'old':OID,'new':OID},('ref','old','new'))
32
+ _HTTP_RECEIVE=ContextVar('msg_git_http_receive',default=False)
33
+ # A separate, deliberately bounded Git pack budget. Two active uploads can use
34
+ # at most 64 MiB of staging space per worker; other protocol requests retain
35
+ # the configured (normally 1 MiB) request limit.
36
+ MAX_GIT_PACK_BYTES=32*1024*1024
37
+ MAX_GIT_UPLOAD_SECONDS=120
38
+ MAX_GIT_REPOSITORY_BYTES=2*1024*1024*1024
39
+ _GIT_UPLOAD_SLOTS=asyncio.Semaphore(2)
40
+ MAX_LFS_OBJECT_BYTES=256*1024*1024
41
+ # Deployment-wide, not per-account. Operators may lower this for their shared
42
+ # volume; PostgreSQL records the value so workers with divergent settings fail.
43
+ DEFAULT_LFS_DEPLOYMENT_BYTES=4*1024*1024*1024
44
+ _LFS_UPLOAD_SLOTS=asyncio.Semaphore(2)
45
+ _LFS_STAGED=ContextVar('msg_lfs_staged',default=None)
46
+
47
+
48
+ def git_repository_bytes(root):
49
+ """Byte size of regular files under root, without following symlinks."""
50
+ total=0
51
+ if not Path(root).is_dir():
52
+ return 0
53
+ for directory,_dirnames,names in os.walk(root,followlinks=False):
54
+ for name in names:
55
+ try:
56
+ total+=(Path(directory)/name).lstat().st_size
57
+ except OSError:
58
+ continue
59
+ if total>MAX_GIT_REPOSITORY_BYTES:
60
+ return total
61
+ return total
62
+
63
+
64
+ def require_git_repository_capacity(root,incoming=MAX_GIT_PACK_BYTES):
65
+ require(type(incoming) is int and incoming>=0,'storage_capacity_exceeded')
66
+ require(git_repository_bytes(root)+incoming<=MAX_GIT_REPOSITORY_BYTES,'storage_capacity_exceeded')
67
+
68
+
69
+ async def spool_git_pack(request,path):
70
+ require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding')
71
+ length=request.headers.get('content-length')
72
+ if length is not None:
73
+ require(length.isdecimal() and int(length)<=MAX_GIT_PACK_BYTES,'request_too_large')
74
+ hasher=hashlib.sha256()
75
+ size=0
76
+ try:
77
+ with path.open('xb') as stream:
78
+ async for chunk in request.stream():
79
+ size+=len(chunk)
80
+ require(size<=MAX_GIT_PACK_BYTES,'request_too_large')
81
+ hasher.update(chunk)
82
+ stream.write(chunk)
83
+ stream.flush()
84
+ os.fsync(stream.fileno())
85
+ except ClientDisconnect as exc:
86
+ raise Failure('request_incomplete') from exc
87
+ return 'sha256:'+hasher.hexdigest(),size
88
+
89
+
90
+ async def spool_lfs_object(request,path,expected_size):
91
+ require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding')
92
+ length=request.headers.get('content-length')
93
+ if length is not None:
94
+ require(length.isdecimal() and int(length)==expected_size,'lfs_size_mismatch')
95
+ hasher=hashlib.sha256();size=0
96
+ try:
97
+ with path.open('xb') as stream:
98
+ async for chunk in request.stream():
99
+ size+=len(chunk)
100
+ require(size<=expected_size,'lfs_size_mismatch')
101
+ hasher.update(chunk);stream.write(chunk)
102
+ stream.flush();os.fsync(stream.fileno())
103
+ except ClientDisconnect as exc:raise Failure('request_incomplete') from exc
104
+ require(size==expected_size,'lfs_size_mismatch')
105
+ return hasher.hexdigest(),size
106
+
107
+
108
+ class NativeGitStore:
109
+ def __init__(self,app):
110
+ self.app=app
111
+ self.root=app.settings.server.repositories_dir
112
+ self.env={'PATH':os.environ.get('PATH','/usr/bin:/bin'),'LANG':'C.UTF-8','HOME':'/nonexistent',
113
+ 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0',
114
+ 'GIT_COMMITTER_NAME':'msg','GIT_COMMITTER_EMAIL':'msg@localhost'}
115
+
116
+ def path(self,id):
117
+ require(bool(re.fullmatch(r'[A-Za-z0-9_-]{1,128}',id)),'invalid_repository_id')
118
+ return self.root/(id+'.git')
119
+
120
+ def lfs(self,id):
121
+ return LFSObjectStore(self.path(id),self.app.settings.server.blob_dir)
122
+
123
+ def lfs_capacity(self,tx):
124
+ configured=os.environ.get('MSG_LFS_DEPLOYMENT_MAX_BYTES',str(DEFAULT_LFS_DEPLOYMENT_BYTES))
125
+ require(configured.isdecimal() and int(configured)>0,'invalid_lfs_deployment_capacity')
126
+ limit=int(configured)
127
+ previous=tx.setting('lfs_deployment_capacity_bytes')
128
+ require(previous is None or previous==limit,'lfs_deployment_capacity_mismatch')
129
+ for root,key in ((self.app.settings.server.blob_dir,'lfs_shared_store_id'),
130
+ (self.root,'lfs_shared_repositories_id')):
131
+ root.mkdir(parents=True,exist_ok=True)
132
+ sentinel=root/'.msg-shared-store-id'
133
+ known=tx.setting(key)
134
+ if not sentinel.exists():
135
+ require(known is None,'lfs_shared_volume_required')
136
+ durable_write(sentinel,(uuid4().hex+'\n').encode())
137
+ identity=sentinel.read_text().strip()
138
+ require(bool(re.fullmatch(r'[0-9a-f]{32}',identity)),'invalid_lfs_shared_store_id')
139
+ require(known is None or known==identity,'lfs_shared_volume_required')
140
+ if known is None:tx.set_setting(key,identity)
141
+ if previous is None:tx.set_setting('lfs_deployment_capacity_bytes',limit)
142
+ return limit
143
+
144
+ def _lfs_packet(self,request,operation,arguments):
145
+ from msg.transports.packet import path_packet
146
+ limits=self.app.settings.server.limits
147
+ signed=request.headers.get('x-msg-request')
148
+ authorization=request.headers.get('authorization','')
149
+ require(not (signed and authorization),'ambiguous_proof')
150
+ if signed:
151
+ packet=path_packet(signed,'j',limits.max_request_bytes)
152
+ require(packet.operation==operation and canonical(packet.arguments)==canonical(arguments),
153
+ 'representation_mismatch')
154
+ return packet
155
+ if authorization:
156
+ require(authorization.startswith('Basic '),'authentication_required')
157
+ try:
158
+ user,password=base64.b64decode(authorization[6:],validate=True).decode('ascii').split(':',1)
159
+ token=unb64(password)
160
+ except (ValueError,UnicodeDecodeError,Failure) as exc:
161
+ raise Failure('invalid_token') from exc
162
+ return request_for(operation,arguments,self.app.settings.service_url,token=(user,token),
163
+ request_id=uuid4().hex if operation=='git.lfs_publish' else None,
164
+ expires_at=self.app.clock()+timedelta(seconds=120))
165
+ return request_for(operation,arguments,self.app.settings.service_url)
166
+
167
+ async def _lfs_authorize(self,request,operation,arguments):
168
+ result=await self.app.executor.execute(self._lfs_packet(request,operation,arguments),entry='network')
169
+ require(result.status=='ok',result.error.code if result.error else 'permission_denied')
170
+ return result.data['resource_id']
171
+
172
+ async def _lfs_preflight_publish(self,request,arguments):
173
+ """Validate the exact signed publish envelope without committing it."""
174
+ packet=self._lfs_packet(request,'git.lfs_publish',arguments)
175
+ spec=self.app.registry.operation(packet.operation,packet.contract_version)
176
+ require('network' in spec.entries,'entry_not_allowed')
177
+ self.app.registry.validate(spec.input_schema,packet.arguments)
178
+ async with self.app.metadata.transaction(write=False) as tx:
179
+ principal=await self.app.authenticator.authenticate(packet,tx,entry='network')
180
+ require(principal.method=='signature','authentication_required')
181
+ context=ExecutionContext(request_id=packet.request_id,principal=principal,entry='network',
182
+ now=self.app.clock(),deadline_monotonic=time.monotonic()+30)
183
+ rid=await resolve(tx,arguments['id'])
184
+ await check_access(self.app,context,packet,tx,rid,'write')
185
+ resource=await tx.resource(rid)
186
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
187
+ return rid
188
+
189
+ async def http_lfs(self,request,repo_id,suffix,*,write=False):
190
+ """LFS batch is a read/planning query; only /-/ object PUT commits bytes."""
191
+ from msg.transports.http import body_bytes,json_response
192
+ require(not request.query_params,'invalid_lfs_query')
193
+ object_match=re.fullmatch(r'objects/([0-9a-f]{64})(?:/([0-9]+))?',suffix)
194
+ if object_match:
195
+ oid,size_text=object_match.groups()
196
+ if write:
197
+ require(request.method=='PUT' and size_text is not None,'method_not_allowed')
198
+ size=int(size_text)
199
+ require(size<=MAX_LFS_OBJECT_BYTES,'request_too_large')
200
+ publish_args={'id':repo_id,'oid':oid,'size':size}
201
+ if request.headers.get('x-msg-request'):
202
+ await self._lfs_preflight_publish(request,publish_args)
203
+ else:
204
+ await self._lfs_authorize(request,'git.lfs_write_authorize',{'id':repo_id})
205
+ self.app.settings.server.staging_dir.mkdir(parents=True,exist_ok=True)
206
+ async with _LFS_UPLOAD_SLOTS:
207
+ with tempfile.TemporaryDirectory(prefix='msg-lfs-',dir=self.app.settings.server.staging_dir) as temp:
208
+ staged=Path(temp)/'object'
209
+ try:
210
+ async with asyncio.timeout(MAX_GIT_UPLOAD_SECONDS):
211
+ digest,actual=await spool_lfs_object(request,staged,size)
212
+ except TimeoutError as exc:
213
+ raise Failure('request_incomplete') from exc
214
+ require(digest==oid,'lfs_digest_mismatch')
215
+ marker=_LFS_STAGED.set(staged)
216
+ try:
217
+ await self._lfs_authorize(request,'git.lfs_publish',publish_args)
218
+ finally:_LFS_STAGED.reset(marker)
219
+ return Response(status_code=200,headers={'Cache-Control':'no-store'})
220
+ require(request.method in {'GET','HEAD'} and size_text is None,'method_not_allowed')
221
+ rid=await self._lfs_authorize(request,'git.lfs_read',{'id':repo_id,'oid':oid})
222
+ path=self.lfs(rid).path(oid)
223
+ require(path.is_file(),'not_found')
224
+ size=path.stat().st_size
225
+ etag='"sha256:'+oid+'"'
226
+ begin,end,status=0,size,200
227
+ requested=request.headers.get('range')
228
+ if requested and request.headers.get('if-range') in {None,etag}:
229
+ match=re.fullmatch(r'bytes=(\d*)-(\d*)',requested) if len(requested)<=128 else None
230
+ if match and any(match.groups()):
231
+ left,right=match.groups()
232
+ if left:
233
+ begin=int(left)
234
+ end=min(size,int(right)+1) if right else size
235
+ valid=begin<size and begin<end
236
+ else:
237
+ suffix=int(right)
238
+ begin=max(0,size-suffix);end=size
239
+ valid=suffix>0 and begin<end
240
+ if valid:status=206
241
+ else:valid=False
242
+ if not valid:
243
+ return Response(status_code=416,headers={'Content-Range':f'bytes */{size}',
244
+ 'Accept-Ranges':'bytes','Cache-Control':'private, no-store'})
245
+ headers={'Content-Length':str(end-begin),'Cache-Control':'private, no-store',
246
+ 'X-Content-Type-Options':'nosniff','Content-Disposition':'attachment',
247
+ 'Accept-Ranges':'bytes','ETag':etag}
248
+ if status==206:headers['Content-Range']=f'bytes {begin}-{end-1}/{size}'
249
+ if request.method=='HEAD':return Response(status_code=status,headers=headers)
250
+ async def stream():
251
+ with path.open('rb') as source:
252
+ source.seek(begin)
253
+ remaining=end-begin
254
+ while remaining:
255
+ chunk=await asyncio.to_thread(source.read,min(65536,remaining))
256
+ require(bool(chunk),'content_truncated')
257
+ remaining-=len(chunk)
258
+ yield chunk
259
+ return StreamingResponse(stream(),status_code=status,media_type='application/octet-stream',headers=headers)
260
+ require(suffix=='objects/batch' and request.method=='POST','not_found')
261
+ require(request.headers.get('content-type','').split(';',1)[0]=='application/vnd.git-lfs+json',
262
+ 'invalid_lfs_content_type')
263
+ import json
264
+ try:body=json.loads(await body_bytes(request,self.app.settings.server.limits.max_request_bytes))
265
+ except (ValueError,UnicodeDecodeError) as exc:raise Failure('invalid_lfs_batch') from exc
266
+ require(type(body) is dict and body.get('operation') in {'download','upload'} and
267
+ type(body.get('objects')) is list and len(body['objects'])<=100,'invalid_lfs_batch')
268
+ upload=body['operation']=='upload'
269
+ require(not upload or write,'method_not_allowed')
270
+ require(not write or upload,'method_not_allowed')
271
+ require(set(body)<={'operation','objects','transfers','ref','hash_algo'},'invalid_lfs_batch')
272
+ rid=await self._lfs_authorize(request,'git.lfs_write_authorize' if upload else 'git.lfs_read_batch',
273
+ {'id':repo_id})
274
+ read_url=self.app.settings.service_url.rstrip('/')+await self._repo_read_path(rid)
275
+ write_url=self.app.settings.service_url.rstrip('/')+'/-/git/'+rid+'/info/lfs'
276
+ objects=[]
277
+ for item in body['objects']:
278
+ require(type(item) is dict and set(item)=={'oid','size'} and
279
+ type(item['oid']) is str and re.fullmatch(r'[0-9a-f]{64}',item['oid']) and
280
+ type(item['size']) is int and 0<=item['size']<=MAX_LFS_OBJECT_BYTES,
281
+ 'invalid_lfs_object')
282
+ oid,size=item['oid'],item['size']
283
+ actual=self.lfs(rid).size(oid)
284
+ if actual is not None and actual!=size:
285
+ objects.append({'oid':oid,'size':size,'error':{'code':422,'message':'size mismatch'}})
286
+ elif upload:
287
+ action={} if actual is not None else {'upload':{'href':write_url+'/objects/'+oid+'/'+str(size),
288
+ 'header':{'Authorization':request.headers.get('authorization','')}}}
289
+ objects.append({'oid':oid,'size':size,'actions':action})
290
+ elif actual is None:
291
+ objects.append({'oid':oid,'size':size,'error':{'code':404,'message':'object missing'}})
292
+ else:
293
+ objects.append({'oid':oid,'size':size,'actions':{'download':{
294
+ 'href':read_url+'/info/lfs/objects/'+oid}}})
295
+ return json_response({'transfer':'basic','objects':objects},headers={'Cache-Control':'no-store',
296
+ 'Content-Type':'application/vnd.git-lfs+json'})
297
+
298
+ async def _repo_read_path(self,rid):
299
+ async with self.app.metadata.transaction(write=False) as tx:
300
+ return await tx.path(rid)
301
+
302
+ def _run(self,id,*args,input=None,stdout=None):
303
+ command=['git','--git-dir',str(self.path(id)),'-c','core.hooksPath=/dev/null',
304
+ '-c','core.fsync=all','-c','protocol.file.allow=always',*args]
305
+ result=subprocess.run(command,input=input,stdout=stdout if stdout is not None else subprocess.PIPE,
306
+ stderr=subprocess.PIPE,env=self.env,timeout=120)
307
+ require(result.returncode==0,'git_operation_failed')
308
+ return result.stdout
309
+
310
+ async def create(self,id):
311
+ path=self.path(id)
312
+ self.root.mkdir(parents=True,exist_ok=True)
313
+ require(not path.exists(),'repository_directory_exists')
314
+ temporary=Path(tempfile.mkdtemp(prefix='.pending-',dir=self.root))
315
+ try:
316
+ result=await asyncio.to_thread(subprocess.run,['git','init','--bare','--initial-branch=main',str(temporary)],
317
+ capture_output=True,env=self.env,timeout=30)
318
+ require(result.returncode==0,'git_initialization_failed')
319
+ # Disable access to unreferenced objects and all implicit remote writes.
320
+ for key,value in {'http.receivepack':'false','http.getanyfile':'false',
321
+ 'uploadpack.allowAnySHA1InWant':'false','uploadpack.allowReachableSHA1InWant':'false',
322
+ 'uploadpack.allowTipSHA1InWant':'false','receive.fsckObjects':'true',
323
+ 'transfer.fsckObjects':'true','core.hooksPath':'/dev/null','core.fsync':'all'}.items():
324
+ result=await asyncio.to_thread(subprocess.run,['git','--git-dir',str(temporary),'config',key,value],
325
+ capture_output=True,env=self.env,timeout=15)
326
+ require(result.returncode==0,'git_initialization_failed')
327
+ durable_write(temporary/'git-daemon-export-ok',b'public resource\n',mode=0o644)
328
+ os.rename(temporary,path)
329
+ fd=os.open(self.root,os.O_RDONLY|os.O_DIRECTORY)
330
+ try:os.fsync(fd)
331
+ finally:os.close(fd)
332
+ finally:
333
+ if temporary.exists():shutil.rmtree(temporary)
334
+
335
+ async def refs(self,id, *, after='',limit=100):
336
+ # Bounded stream: do not put the complete ref set into server memory.
337
+ process=await asyncio.create_subprocess_exec('git','--git-dir',str(self.path(id)),
338
+ 'for-each-ref','--format=%(refname) %(objectname)','--sort=refname','refs/heads','refs/tags',
339
+ stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.DEVNULL,env=self.env)
340
+ items=[];more=False
341
+ try:
342
+ while line:=await process.stdout.readline():
343
+ name,oid=line.decode('utf-8').rstrip('\n').split(' ',1)
344
+ if name<=after:continue
345
+ if len(items)>=limit:more=True;break
346
+ items.append({'name':name,'oid':oid})
347
+ if not more:require(await process.wait()==0,'git_operation_failed')
348
+ finally:
349
+ if process.returncode is None:process.kill();await process.wait()
350
+ return items,items[-1]['name'] if more else None
351
+
352
+ async def import_bundle(self,id,bundle_path,changes,force=False):
353
+ require(self.path(id).is_dir(),'repository_missing')
354
+ # bundle unbundle imports object data only. No refs move yet; no commands,
355
+ # hooks, repository config or external remote URL come from the bundle.
356
+ await asyncio.to_thread(self._run,id,'bundle','verify',str(bundle_path))
357
+ await asyncio.to_thread(self._run,id,'bundle','unbundle',str(bundle_path))
358
+ await asyncio.to_thread(self._run,id,'fsck','--no-reflogs','--connectivity-only')
359
+ for change in changes:
360
+ name=change['ref']
361
+ require(re.fullmatch(r'refs/(heads|tags)/[^\s]+',name) is not None,'invalid_git_ref')
362
+ await asyncio.to_thread(self._run,id,'check-ref-format',name)
363
+ old,new=change['old'],change['new']
364
+ if new:
365
+ # Branch tips must be commits; tags may reference another object.
366
+ suffix='^{commit}' if name.startswith('refs/heads/') else '^{object}'
367
+ await asyncio.to_thread(self._run,id,'rev-parse','--verify',new+suffix)
368
+ if old and new and not force and name.startswith('refs/heads/'):
369
+ await asyncio.to_thread(self._run,id,'merge-base','--is-ancestor',old,new)
370
+ return changes
371
+
372
+ async def update_refs(self,id,changes):
373
+ lines=['start']
374
+ zero='0'*40
375
+ for change in changes:
376
+ if change['new'] is None:
377
+ require(change['old'] is not None,'invalid_git_change')
378
+ lines.append('delete '+change['ref']+' '+change['old'])
379
+ else:
380
+ lines.append('update '+change['ref']+' '+change['new']+' '+(change['old'] or zero))
381
+ lines+=['prepare','commit','']
382
+ await asyncio.to_thread(self._run,id,'update-ref','--stdin',input='\n'.join(lines).encode())
383
+
384
+ async def http(self,request,repo_path,suffix):
385
+ require(suffix!='git-receive-pack','method_not_allowed')
386
+ require(suffix in {'info/refs','git-upload-pack','HEAD'},'not_found')
387
+ require((request.method in {'GET','HEAD'} and suffix in {'info/refs','HEAD'}) or
388
+ (request.method=='POST' and suffix=='git-upload-pack'),'method_not_allowed')
389
+ query=request.query_params
390
+ require(not query or dict(query)=={'service':'git-upload-pack'},'git_push_requires_authenticated_transport')
391
+ result=await self.app.executor.execute(request_for('git.refs',{'id':repo_path,'limit':1},self.app.settings.service_url))
392
+ require(result.status=='ok',result.error.code if result.error else 'permission_denied')
393
+ rid=result.data['resource_id']
394
+ from msg.transports.http import body_bytes
395
+ body=await body_bytes(request,self.app.settings.server.limits.max_request_bytes) if request.method=='POST' else b''
396
+ env={**self.env,'GIT_PROJECT_ROOT':str(self.root),'PATH_INFO':'/'+rid+'.git/'+suffix,
397
+ 'REQUEST_METHOD':'GET' if request.method=='HEAD' else request.method,
398
+ 'QUERY_STRING':'service=git-upload-pack' if query else '',
399
+ 'CONTENT_TYPE':'application/x-git-upload-pack-request' if body else '', 'CONTENT_LENGTH':str(len(body))}
400
+ protocol=request.headers.get('git-protocol')
401
+ if protocol:
402
+ require(protocol in {'version=0','version=1','version=2'},'invalid_git_protocol')
403
+ env['GIT_PROTOCOL']=protocol
404
+ process=await asyncio.create_subprocess_exec('git','http-backend',stdin=asyncio.subprocess.PIPE,
405
+ stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.DEVNULL,env=env,limit=65536)
406
+ async def feed():
407
+ try:
408
+ process.stdin.write(body);await process.stdin.drain()
409
+ finally:process.stdin.close()
410
+ sender=asyncio.create_task(feed())
411
+ headers={};size=0;status=200
412
+ try:
413
+ while True:
414
+ line=await asyncio.wait_for(process.stdout.readline(),30)
415
+ size+=len(line)
416
+ require(line and size<=8192,'invalid_git_response')
417
+ if line in {b'\r\n',b'\n'}:break
418
+ key,_,value=line.decode('latin1').partition(':')
419
+ if key.lower()=='status':status=int(value.strip().split(' ',1)[0])
420
+ elif key.lower() in {'content-type','cache-control','expires','pragma'}:headers[key]=value.strip()
421
+ if request.method=='HEAD':
422
+ if process.returncode is None:process.kill()
423
+ await process.wait();await sender
424
+ return Response(status_code=status,headers=headers)
425
+ except BaseException:
426
+ if process.returncode is None:process.kill()
427
+ await process.wait();sender.cancel()
428
+ raise
429
+ async def stream():
430
+ try:
431
+ while chunk:=await asyncio.wait_for(process.stdout.read(65536),120):yield chunk
432
+ await sender
433
+ require(await process.wait()==0,'git_operation_failed')
434
+ finally:
435
+ if process.returncode is None:process.kill();await process.wait()
436
+ if not sender.done():sender.cancel()
437
+ return StreamingResponse(stream(),status_code=status,headers={**headers,'X-Content-Type-Options':'nosniff'})
438
+
439
+ async def http_push(self,request,rid,suffix):
440
+ from msg.transports.http import json_response,error_status
441
+ from msg.transports.packet import path_packet
442
+ require(bool(re.fullmatch(r'[A-Za-z0-9_-]{1,128}',rid)),'not_found')
443
+ require(suffix in {'info/refs','git-receive-pack'},'not_found')
444
+ advertise=suffix=='info/refs'
445
+ require(request.method==('GET' if advertise else 'POST'),'method_not_allowed')
446
+ pairs=request.query_params.multi_items()
447
+ require(len(pairs)==len({name for name,_ in pairs}),'duplicate_query_parameter')
448
+ require(dict(request.query_params)==({'service':'git-receive-pack'} if advertise else {}),
449
+ 'invalid_git_service')
450
+ if not advertise:
451
+ require(request.headers.get('content-type','').split(';',1)[0]==
452
+ 'application/x-git-receive-pack-request','invalid_git_content_type')
453
+ limits=self.app.settings.server.limits
454
+ operation='git.http_advertise' if advertise else 'git.http_receive'
455
+ signed=request.headers.get('x-msg-request')
456
+ authorization=request.headers.get('authorization','')
457
+ require(not (signed and authorization),'ambiguous_proof')
458
+ if not signed and not authorization:
459
+ return json_response({'status':'error','error':{'code':'authentication_required',
460
+ 'retryable':False}},401,
461
+ headers={'WWW-Authenticate':'Basic realm="msg Git"'})
462
+ if not advertise:
463
+ return await self._http_push_receive(request,rid,operation,signed,authorization,limits)
464
+ arguments={'id':rid}
465
+ if signed:
466
+ packet=path_packet(signed,'j',limits.max_request_bytes)
467
+ require(packet.operation==operation and canonical(packet.arguments)==canonical(arguments),
468
+ 'representation_mismatch')
469
+ else:
470
+ require(authorization.startswith('Basic '),'authentication_required')
471
+ try:
472
+ user,password=base64.b64decode(authorization[6:],validate=True).decode('ascii').split(':',1)
473
+ token=unb64(password)
474
+ except (ValueError,UnicodeDecodeError,Failure) as exc:
475
+ raise Failure('invalid_token') from exc
476
+ packet=request_for(operation,arguments,self.app.settings.service_url,token=(user,token),
477
+ expires_at=self.app.clock()+timedelta(seconds=120))
478
+ marker=_HTTP_RECEIVE.set(False)
479
+ try:
480
+ result=await self.app.executor.execute(packet,entry='network')
481
+ finally:
482
+ _HTTP_RECEIVE.reset(marker)
483
+ if result.error:
484
+ return json_response({'status':'error','error':wire(result.error)},
485
+ error_status(result.error.code))
486
+ process=await asyncio.create_subprocess_exec('git','--git-dir',str(self.path(rid)),
487
+ 'receive-pack','--stateless-rpc','--advertise-refs',str(self.path(rid)),
488
+ stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.DEVNULL,env=self.env)
489
+ chunks=[]
490
+ size=0
491
+ try:
492
+ while chunk:=await asyncio.wait_for(process.stdout.read(65536),30):
493
+ size+=len(chunk)
494
+ require(size<=limits.max_response_bytes,'response_too_large')
495
+ chunks.append(chunk)
496
+ require(await asyncio.wait_for(process.wait(),30)==0,'git_operation_failed')
497
+ finally:
498
+ if process.returncode is None:
499
+ process.kill()
500
+ await process.wait()
501
+ output=b''.join(chunks)
502
+ prefix=b'# service=git-receive-pack\n'
503
+ payload=f'{len(prefix)+4:04x}'.encode()+prefix+b'0000'+output
504
+ require(len(payload)<=limits.max_response_bytes,'response_too_large')
505
+ return Response(payload,media_type='application/x-git-receive-pack-advertisement',
506
+ headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
507
+
508
+ async def _http_push_receive(self,request,rid,operation,signed,authorization,limits):
509
+ from msg.transports.http import json_response,error_status
510
+ from msg.transports.packet import path_packet
511
+ # Reject missing/invalid request IDs before accepting a pack. This also
512
+ # leaves no job or staged file when a client disconnects during upload.
513
+ if signed:
514
+ packet=path_packet(signed,'j',limits.max_request_bytes)
515
+ require(packet.operation==operation and bool(packet.request_id),'git_request_id_required')
516
+ else:
517
+ require(authorization.startswith('Basic '),'authentication_required')
518
+ request_id=request.headers.get('x-msg-request-id')
519
+ require(request_id is not None and re.fullmatch(r'[A-Za-z0-9._:-]{1,128}',request_id)
520
+ is not None,'git_request_id_required')
521
+ try:
522
+ user,password=base64.b64decode(authorization[6:],validate=True).decode('ascii').split(':',1)
523
+ token=unb64(password)
524
+ except (ValueError,UnicodeDecodeError,Failure) as exc:
525
+ raise Failure('invalid_token') from exc
526
+ self.app.settings.server.staging_dir.mkdir(parents=True,exist_ok=True)
527
+ async with _GIT_UPLOAD_SLOTS:
528
+ with tempfile.TemporaryDirectory(prefix='msg-git-http-',dir=self.app.settings.server.staging_dir) as temp:
529
+ pack_path=Path(temp)/'receive.pack'
530
+ try:
531
+ async with asyncio.timeout(MAX_GIT_UPLOAD_SECONDS):
532
+ pack_digest,pack_size=await spool_git_pack(request,pack_path)
533
+ except TimeoutError as exc:
534
+ raise Failure('request_incomplete') from exc
535
+ arguments={'id':rid,'pack_digest':pack_digest,'pack_size':pack_size}
536
+ if signed:
537
+ require(canonical(packet.arguments)==canonical(arguments),'representation_mismatch')
538
+ else:
539
+ packet=request_for(operation,arguments,self.app.settings.service_url,token=(user,token),
540
+ request_id=request_id,expires_at=self.app.clock()+timedelta(seconds=120))
541
+ if pack_size==4 and pack_path.read_bytes()==b'0000':
542
+ # Git's remote-curl probe_rpc sends a complete 0000 flush
543
+ # before a large chunked upload. The probe is not a ref
544
+ # update: authenticate and recheck write ACL, but leave its
545
+ # request ID unused for the real receive-pack POST.
546
+ if signed:
547
+ spec=self.app.registry.operation(packet.operation,packet.contract_version)
548
+ self.app.registry.validate(spec.input_schema,packet.arguments)
549
+ async with self.app.metadata.transaction(write=False) as tx:
550
+ principal=await self.app.authenticator.authenticate(packet,tx,entry='network')
551
+ context=ExecutionContext(request_id=packet.request_id,principal=principal,entry='network',
552
+ now=self.app.clock(),deadline_monotonic=time.monotonic()+30)
553
+ resource=await tx.resource(rid)
554
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
555
+ await check_access(self.app,context,packet,tx,rid,'write')
556
+ else:
557
+ preflight=request_for('git.http_advertise',{'id':rid},self.app.settings.service_url,
558
+ token=(user,token),expires_at=self.app.clock()+timedelta(seconds=120))
559
+ checked=await self.app.executor.execute(preflight,entry='network')
560
+ if checked.error:
561
+ return json_response({'status':'error','error':wire(checked.error)},
562
+ error_status(checked.error.code))
563
+ return Response(b'0000',media_type='application/x-git-receive-pack-result',
564
+ headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
565
+ marker=_HTTP_RECEIVE.set(True)
566
+ try:
567
+ result=await self.app.executor.execute(packet,entry='network')
568
+ finally:
569
+ _HTTP_RECEIVE.reset(marker)
570
+ if result.error:
571
+ return json_response({'status':'error','error':wire(result.error)},
572
+ error_status(result.error.code))
573
+ job_id=result.data['job_id']
574
+ if result.replayed:
575
+ async with self.app.metadata.transaction(write=False) as tx:
576
+ cached=tx.setting('git_http_result:'+job_id)
577
+ status=tx.setting('job_status:'+job_id)
578
+ if cached is None:
579
+ raise Failure(status['code'] if status and status['code'] not in {'ok'} else 'external_uncertain')
580
+ return Response(unb64(cached),media_type='application/x-git-receive-pack-result',
581
+ headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
582
+ from msg.extensions.ssh_git import guarded_command
583
+ require_git_repository_capacity(self.path(rid),incoming=pack_size)
584
+ code,output,_changed=await guarded_command(self.app,await self._job(job_id),
585
+ lambda store:['receive-pack','--stateless-rpc',str(store.path(rid))],
586
+ input_file=pack_path,capture_output=True,output_limit=limits.max_response_bytes,timeout=600,
587
+ cache_result_key='git_http_result:'+job_id)
588
+ require(code==0,'git_operation_failed')
589
+ return Response(output,media_type='application/x-git-receive-pack-result',
590
+ headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
591
+
592
+ async def _job(self,id):
593
+ async with self.app.metadata.transaction(write=False) as tx:
594
+ return await tx.job(id)
595
+
596
+
597
+ def register(app,op):
598
+ @op('git.lfs_read_batch',obj({'id':IDENTIFIER},('id',)),effect='read')
599
+ async def lfs_read_batch(ctx,request,tx):
600
+ rid=await resolve(tx,request.arguments['id'])
601
+ await check_access(app,ctx,request,tx,rid,'read')
602
+ resource=await tx.resource(rid)
603
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
604
+ return HandlerOutput(data={'resource_id':rid})
605
+
606
+ @op('git.lfs_read',obj({'id':IDENTIFIER,'oid':{'type':'string','pattern':'^[0-9a-f]{64}$'}},
607
+ ('id','oid')),effect='read')
608
+ async def lfs_read(ctx,request,tx):
609
+ rid=await resolve(tx,request.arguments['id'])
610
+ await check_access(app,ctx,request,tx,rid,'read')
611
+ resource=await tx.resource(rid)
612
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
613
+ return HandlerOutput(data={'resource_id':rid})
614
+
615
+ @op('git.lfs_write_authorize',obj({'id':IDENTIFIER},('id',)),effect='read')
616
+ async def lfs_write_authorize(ctx,request,tx):
617
+ require(ctx.principal.method in {'signature','token'},'authentication_required')
618
+ rid=await resolve(tx,request.arguments['id'])
619
+ await check_access(app,ctx,request,tx,rid,'write')
620
+ resource=await tx.resource(rid)
621
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
622
+ return HandlerOutput(data={'resource_id':rid})
623
+
624
+ @op('git.lfs_publish',obj({'id':IDENTIFIER,'oid':{'type':'string','pattern':'^[0-9a-f]{64}$'},
625
+ 'size':{'type':'integer','minimum':0,'maximum':MAX_LFS_OBJECT_BYTES}},('id','oid','size')))
626
+ async def lfs_publish(ctx,request,tx):
627
+ staged=_LFS_STAGED.get()
628
+ require(staged is not None and staged.is_file(),'lfs_transport_required')
629
+ require(ctx.principal.method in {'signature','token'},'authentication_required')
630
+ rid=await resolve(tx,request.arguments['id'])
631
+ await check_access(app,ctx,request,tx,rid,'write')
632
+ resource=await tx.resource(rid)
633
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
634
+ store=NativeGitStore(app)
635
+ limit=store.lfs_capacity(tx)
636
+ await asyncio.to_thread(store.lfs(rid).publish,request.arguments['oid'],
637
+ staged,request.arguments['size'],quota_bytes=limit)
638
+ return HandlerOutput(data={'resource_id':rid,'oid':request.arguments['oid'],
639
+ 'size':request.arguments['size']})
640
+
641
+ @op('git.lfs_migrate',obj({'id':IDENTIFIER,
642
+ 'oid':{'type':'string','pattern':'^[0-9a-f]{64}$'}},('id','oid')))
643
+ async def lfs_migrate(ctx,request,tx):
644
+ require(ctx.principal.method in {'signature','token'},'authentication_required')
645
+ rid=await resolve(tx,request.arguments['id'])
646
+ await check_access(app,ctx,request,tx,rid,'write')
647
+ resource=await tx.resource(rid)
648
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
649
+ store=NativeGitStore(app)
650
+ limit=store.lfs_capacity(tx)
651
+ result=await asyncio.to_thread(store.lfs(rid).migrate_legacy,
652
+ request.arguments['oid'],quota_bytes=limit)
653
+ return HandlerOutput(data={'resource_id':rid,'oid':request.arguments['oid'],**result})
654
+
655
+ @op('git.create',obj({'parent':IDENTIFIER,'name':STRING},('parent','name')),signature=True)
656
+ async def create(ctx,request,tx):
657
+ parent=await tx.resource(await resolve(tx,request.arguments['parent']))
658
+ require(parent.type in {'user','organization'},'repository_namespace_required')
659
+ name=request.arguments['name']
660
+ require(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,90}\.git',name) is not None,'invalid_repository_name')
661
+ await check_access(app,ctx,request,tx,parent.id,'create')
662
+ require(parent.mode&1 and all(r.mode&1 for r in await tx.ancestors(parent.id)),'repo_public_read_required')
663
+ resource=await create_resource(app,ctx,request,tx,parent=parent.id,type='repo',name=name,
664
+ mode=0o664 if parent.type=='organization' else 0o644)
665
+ await NativeGitStore(app).create(resource.id)
666
+ path=await tx.path(resource.id)
667
+ return output_for(resource,path=path,public=True,
668
+ read_url=app.settings.service_url.rstrip('/')+path,
669
+ push_url=app.settings.service_url.rstrip('/')+'/-/git/'+resource.id,
670
+ push_max_bytes=MAX_GIT_PACK_BYTES)
671
+
672
+ @op('git.refs',obj({'id':IDENTIFIER,'cursor':STRING,'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read')
673
+ async def refs(ctx,request,tx):
674
+ rid=await resolve(tx,request.arguments['id'])
675
+ await check_access(app,ctx,request,tx,rid,'read')
676
+ resource=await tx.resource(rid)
677
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
678
+ after=app.cursors.decode(request.arguments['cursor'],'git.refs',rid) if request.arguments.get('cursor') else ''
679
+ rows,following=await NativeGitStore(app).refs(rid,after=after,limit=request.arguments.get('limit',100))
680
+ return HandlerOutput(data={'resource_id':rid,'refs':rows,
681
+ 'read_url':app.settings.service_url.rstrip('/')+await tx.path(rid),
682
+ 'push_url':app.settings.service_url.rstrip('/')+'/-/git/'+rid,
683
+ 'push_max_bytes':MAX_GIT_PACK_BYTES,
684
+ 'cursor':app.cursors.encode('git.refs',rid,following) if following else None})
685
+
686
+ @op('git.http_advertise',obj({'id':IDENTIFIER},('id',)),effect='read')
687
+ async def http_advertise(ctx,request,tx):
688
+ require(ctx.principal.method in {'signature','token'},'authentication_required')
689
+ rid=await resolve(tx,request.arguments['id'])
690
+ resource=await tx.resource(rid)
691
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
692
+ await check_access(app,ctx,request,tx,rid,'write')
693
+ return HandlerOutput(data={'resource_id':rid})
694
+
695
+ @op('git.http_receive',obj({'id':IDENTIFIER,'pack_digest':STRING,
696
+ 'pack_size':{'type':'integer','minimum':0}},('id','pack_digest','pack_size')),effect='external')
697
+ async def http_receive(ctx,request,tx):
698
+ require(_HTTP_RECEIVE.get(),'git_http_transport_required')
699
+ require(ctx.principal.method in {'signature','token'},'authentication_required')
700
+ rid=await resolve(tx,request.arguments['id'])
701
+ resource=await tx.resource(rid)
702
+ require(resource.type=='repo' and resource.state=='active','not_a_repository')
703
+ await check_access(app,ctx,request,tx,rid,'write')
704
+ require(request.arguments['pack_digest'].startswith('sha256:') and
705
+ len(request.arguments['pack_digest'])==71,'invalid_pack_digest')
706
+ id,eid=new_id('job'),event_id(request,ctx.principal.subject)
707
+ job=EffectJob(id=id,event_id=eid,kind='git.receive',dedupe_key='git-http:'+eid,
708
+ principal=ctx.principal,operation=request.operation,
709
+ arguments={'id':rid,'request_id':request.request_id},state='running',attempts=1,
710
+ next_attempt_at=ctx.now,lease_until=ctx.now+timedelta(seconds=600))
711
+ await tx.enqueue(job)
712
+ return HandlerOutput(resources=(ResourceRef(id=rid),),data={'job_id':id})
713
+
714
+ @op('git.push',obj({'id':IDENTIFIER,'bundle':REF,'changes':{'type':'array','items':CHANGE,'minItems':1,'maxItems':128},
715
+ 'force':BOOLEAN},('id','bundle','changes')),effect='external',signature=True)
716
+ async def push(ctx,request,tx):
717
+ rid=await resolve(tx,request.arguments['id'])
718
+ require((await tx.resource(rid)).type=='repo','not_a_repository')
719
+ await check_access(app,ctx,request,tx,rid,'write')
720
+ source=decode(ResourceRef,request.arguments['bundle']);require(source.revision is not None,'source_revision_required')
721
+ await check_access(app,ctx,request,tx,source.id,'read');await tx.revision(source)
722
+ require(len({v['ref'] for v in request.arguments['changes']})==len(request.arguments['changes']),'duplicate_git_ref')
723
+ eid=event_id(request,ctx.principal.subject)
724
+ job=EffectJob(id=new_id('job'),event_id=eid,kind='git.push',dedupe_key='git:'+eid,principal=ctx.principal,
725
+ operation=request.operation,arguments={'id':rid,'bundle':wire(source),'changes':request.arguments['changes'],
726
+ 'force':request.arguments.get('force',False),'request_id':request.request_id},state='pending',attempts=0,
727
+ next_attempt_at=ctx.now,lease_until=None)
728
+ await tx.enqueue(job)
729
+ return HandlerOutput(resources=(ResourceRef(id=rid),),data={'job_id':job.id,'state':'pending'})
730
+
731
+
732
+ async def execute_push(app,job):
733
+ from msg.workers.effects import current_principal,worker_context,effect_request
734
+ from msg.workers.leases import current_attempt
735
+ store=NativeGitStore(app)
736
+ app.settings.server.staging_dir.mkdir(parents=True,exist_ok=True)
737
+ with tempfile.TemporaryDirectory(prefix='git-push-',dir=app.settings.server.staging_dir) as temp:
738
+ bundle=Path(temp)/'input.bundle'
739
+ async with app.metadata.transaction(write=False) as tx:
740
+ principal=await current_principal(app,job.principal,tx)
741
+ context,request=worker_context(app,job,principal),effect_request(app,job,principal)
742
+ await check_access(app,context,request,tx,job.arguments['id'],'write')
743
+ ref=decode(ResourceRef,job.arguments['bundle'])
744
+ await check_access(app,context,request,tx,ref.id,'read')
745
+ rev=await tx.revision(ref)
746
+ with bundle.open('xb') as output:
747
+ async for chunk in app.contents.read(rev.content):output.write(chunk)
748
+ require_git_repository_capacity(store.path(job.arguments['id']),incoming=bundle.stat().st_size)
749
+ await store.import_bundle(job.arguments['id'],bundle,job.arguments['changes'],job.arguments.get('force',False))
750
+ moved=False
751
+ try:
752
+ async with app.metadata.transaction(write=True) as tx:
753
+ principal=await current_principal(app,job.principal,tx)
754
+ context,request=worker_context(app,job,principal),effect_request(app,job,principal)
755
+ await check_access(app,context,request,tx,job.arguments['id'],'write')
756
+ require(tx.setting('runtime_config',{}).get('accept_writes',True),'writes_paused')
757
+ current=await current_attempt(app,tx,job)
758
+ if current is None:
759
+ return
760
+ await store.update_refs(job.arguments['id'],job.arguments['changes'])
761
+ moved=True
762
+ resource=await tx.resource(job.arguments['id'])
763
+ await tx.replace(replace(resource,generation=resource.generation+1,modified_at=app.clock(),
764
+ modified_by=principal.actor),resource.generation)
765
+ await tx.save_job(replace(current,state='done',lease_until=None))
766
+ tx.set_setting('job_status:'+job.id,{'code':'ok','refs':job.arguments['changes']})
767
+ except BaseException as exc:
768
+ if moved:raise Failure('external_uncertain') from exc
769
+ raise