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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- 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
|