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
msg/storage/git.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Private Git text storage and streamed binary CAS, never a public Git endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import fcntl
|
|
7
|
+
import stat
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from msg.core.codec import canonical, digest, loads
|
|
18
|
+
from msg.core.errors import Failure, require
|
|
19
|
+
from msg.core.models import BlobRef
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def durable_write(path: Path, data: bytes, mode: int = 0o600):
|
|
23
|
+
path.parent.mkdir(parents=True,exist_ok=True)
|
|
24
|
+
fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent)
|
|
25
|
+
try:
|
|
26
|
+
os.fchmod(fd,mode)
|
|
27
|
+
with os.fdopen(fd,'wb') as stream:
|
|
28
|
+
stream.write(data)
|
|
29
|
+
stream.flush()
|
|
30
|
+
os.fsync(stream.fileno())
|
|
31
|
+
os.replace(temporary,path)
|
|
32
|
+
directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY)
|
|
33
|
+
try:
|
|
34
|
+
os.fsync(directory)
|
|
35
|
+
finally:
|
|
36
|
+
os.close(directory)
|
|
37
|
+
finally:
|
|
38
|
+
if os.path.exists(temporary):
|
|
39
|
+
os.unlink(temporary)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LFSObjectStore:
|
|
43
|
+
"""Verified shared bytes; repository hardlinks remain the only ACL roots.
|
|
44
|
+
|
|
45
|
+
Publishers still hold the metadata transaction for current authorization.
|
|
46
|
+
A shared filesystem lock also serializes LFS capacity, migration and GC
|
|
47
|
+
across worker processes. It is not a substitute for PostgreSQL authority.
|
|
48
|
+
"""
|
|
49
|
+
def __init__(self, repo: Path, shared_root: Path | None = None):
|
|
50
|
+
self.root=Path(repo)/'lfs'/'objects'
|
|
51
|
+
self.shared=Path(shared_root) if shared_root is not None else None
|
|
52
|
+
|
|
53
|
+
def path(self, oid: str) -> Path:
|
|
54
|
+
require(isinstance(oid,str) and re.fullmatch(r'[0-9a-f]{64}',oid) is not None,
|
|
55
|
+
'invalid_lfs_oid')
|
|
56
|
+
return self.root/oid[:2]/oid[2:4]/oid
|
|
57
|
+
|
|
58
|
+
def size(self, oid: str) -> int | None:
|
|
59
|
+
path=self.path(oid)
|
|
60
|
+
require(not path.is_symlink(),'lfs_object_conflict')
|
|
61
|
+
return path.stat().st_size if path.is_file() else None
|
|
62
|
+
|
|
63
|
+
def _shared_path(self,oid: str) -> Path:
|
|
64
|
+
require(self.shared is not None,'lfs_shared_store_required')
|
|
65
|
+
self.path(oid)
|
|
66
|
+
return self.shared/oid
|
|
67
|
+
|
|
68
|
+
@contextmanager
|
|
69
|
+
def _locked(self, *, timeout=30.0):
|
|
70
|
+
require(self.shared is not None,'lfs_shared_store_required')
|
|
71
|
+
require(not self.shared.is_symlink(),'lfs_shared_volume_required')
|
|
72
|
+
self.shared.mkdir(parents=True,exist_ok=True)
|
|
73
|
+
try:
|
|
74
|
+
fd=os.open(self.shared/'.msg-lfs.lock',
|
|
75
|
+
os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW|os.O_CLOEXEC,0o600)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise Failure('lfs_object_conflict') from exc
|
|
78
|
+
try:
|
|
79
|
+
require(stat.S_ISREG(os.fstat(fd).st_mode),'lfs_object_conflict')
|
|
80
|
+
deadline=time.monotonic()+timeout
|
|
81
|
+
while True:
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
84
|
+
break
|
|
85
|
+
except BlockingIOError:
|
|
86
|
+
require(time.monotonic()<deadline,'server_busy',retryable=True)
|
|
87
|
+
time.sleep(min(0.01,max(0,deadline-time.monotonic())))
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
os.close(fd)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _sync(directory):
|
|
94
|
+
fd=os.open(directory,os.O_RDONLY|os.O_DIRECTORY)
|
|
95
|
+
try:os.fsync(fd)
|
|
96
|
+
finally:os.close(fd)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _verify(path,oid,size):
|
|
100
|
+
"""Do not follow links or accept same-size corruption/replaced bytes."""
|
|
101
|
+
require(type(size) is int and 0<=size<=2**63-1,'lfs_size_mismatch')
|
|
102
|
+
try:
|
|
103
|
+
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK|os.O_CLOEXEC)
|
|
104
|
+
except FileNotFoundError as exc:
|
|
105
|
+
raise Failure('lfs_object_missing') from exc
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise Failure('lfs_object_conflict') from exc
|
|
108
|
+
with os.fdopen(fd,'rb') as source:
|
|
109
|
+
before=os.fstat(source.fileno())
|
|
110
|
+
require(stat.S_ISREG(before.st_mode),'lfs_object_conflict')
|
|
111
|
+
require(before.st_size==size,'lfs_size_mismatch')
|
|
112
|
+
hasher=hashlib.sha256()
|
|
113
|
+
actual=0
|
|
114
|
+
while chunk:=source.read(1024*1024):
|
|
115
|
+
actual+=len(chunk)
|
|
116
|
+
require(actual<=size,'lfs_size_mismatch')
|
|
117
|
+
hasher.update(chunk)
|
|
118
|
+
after=os.fstat(source.fileno())
|
|
119
|
+
require(actual==size,'lfs_size_mismatch')
|
|
120
|
+
require(hasher.hexdigest()==oid,'lfs_digest_mismatch')
|
|
121
|
+
require((before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)==
|
|
122
|
+
(after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns),
|
|
123
|
+
'lfs_object_conflict')
|
|
124
|
+
return after
|
|
125
|
+
|
|
126
|
+
def shared_usage(self) -> int:
|
|
127
|
+
require(self.shared is not None,'lfs_shared_store_required')
|
|
128
|
+
if not self.shared.exists():return 0
|
|
129
|
+
total=0
|
|
130
|
+
for path in self.shared.iterdir():
|
|
131
|
+
if not re.fullmatch(r'[0-9a-f]{64}',path.name):continue
|
|
132
|
+
info=path.lstat()
|
|
133
|
+
require(stat.S_ISREG(info.st_mode),'lfs_object_conflict')
|
|
134
|
+
total+=info.st_size
|
|
135
|
+
return total
|
|
136
|
+
|
|
137
|
+
def _publish(self,oid,staged,size,quota_bytes):
|
|
138
|
+
destination=self.path(oid)
|
|
139
|
+
shared=self._shared_path(oid)
|
|
140
|
+
if quota_bytes is not None:
|
|
141
|
+
require(type(quota_bytes) is int and quota_bytes>=0,'invalid_lfs_deployment_capacity')
|
|
142
|
+
require(not any(path.is_symlink() for path in
|
|
143
|
+
(self.root,self.root.parent,destination.parent.parent,destination.parent)),
|
|
144
|
+
'lfs_object_conflict')
|
|
145
|
+
self._verify(staged,oid,size)
|
|
146
|
+
if destination.exists() or destination.is_symlink():
|
|
147
|
+
self._verify(destination,oid,size)
|
|
148
|
+
destination.parent.mkdir(parents=True,exist_ok=True)
|
|
149
|
+
require(shared.parent.stat().st_dev==destination.parent.stat().st_dev,
|
|
150
|
+
'lfs_shared_volume_required')
|
|
151
|
+
if shared.exists() or shared.is_symlink():
|
|
152
|
+
self._verify(shared,oid,size)
|
|
153
|
+
else:
|
|
154
|
+
if quota_bytes is not None:
|
|
155
|
+
require(self.shared_usage()+size<=quota_bytes,'lfs_deployment_capacity_exceeded')
|
|
156
|
+
fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=shared.parent)
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(fd,'wb') as target,staged.open('rb') as source:
|
|
159
|
+
hasher=hashlib.sha256()
|
|
160
|
+
actual=0
|
|
161
|
+
while chunk:=source.read(1024*1024):
|
|
162
|
+
actual+=len(chunk)
|
|
163
|
+
require(actual<=size,'lfs_size_mismatch')
|
|
164
|
+
hasher.update(chunk);target.write(chunk)
|
|
165
|
+
require(actual==size,'lfs_size_mismatch')
|
|
166
|
+
require(hasher.hexdigest()==oid,'lfs_digest_mismatch')
|
|
167
|
+
target.flush();os.fsync(target.fileno())
|
|
168
|
+
# Never overwrite an existing canonical inode: other repositories
|
|
169
|
+
# can already hold hardlinks to it. A collision is reverified.
|
|
170
|
+
try:os.link(temporary,shared)
|
|
171
|
+
except FileExistsError:self._verify(shared,oid,size)
|
|
172
|
+
self._sync(shared.parent)
|
|
173
|
+
finally:
|
|
174
|
+
os.unlink(temporary)
|
|
175
|
+
if destination.exists() and os.path.samefile(shared,destination):
|
|
176
|
+
return False
|
|
177
|
+
# An atomic link replacement retains a legacy ACL root through every
|
|
178
|
+
# crash point. No published path ever points at a partly copied file.
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix='.pending-',dir=destination.parent) as temp:
|
|
180
|
+
linked=Path(temp)/'object'
|
|
181
|
+
os.link(shared,linked)
|
|
182
|
+
os.replace(linked,destination)
|
|
183
|
+
self._sync(destination.parent)
|
|
184
|
+
self._sync(destination.parent)
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def publish(self, oid: str, staged: Path, size: int, *, quota_bytes: int | None = None):
|
|
188
|
+
self.path(oid)
|
|
189
|
+
with self._locked():
|
|
190
|
+
self._publish(oid,Path(staged),size,quota_bytes)
|
|
191
|
+
|
|
192
|
+
def migrate_legacy(self,oid: str, *, quota_bytes: int | None = None):
|
|
193
|
+
"""Explicit authenticated write, never called by HEAD/GET/stat/batch."""
|
|
194
|
+
source=self.path(oid)
|
|
195
|
+
with self._locked():
|
|
196
|
+
require(source.is_file() and not source.is_symlink(),'lfs_object_missing')
|
|
197
|
+
size=source.stat().st_size
|
|
198
|
+
changed=self._publish(oid,source,size,quota_bytes)
|
|
199
|
+
return {'state':'migrated' if changed else 'already_shared','size':size}
|
|
200
|
+
|
|
201
|
+
def collect_unlinked(self, *, older_than: float, index: Path) -> tuple[int,int]:
|
|
202
|
+
"""Collect only after all repository links and content references vanish."""
|
|
203
|
+
require(self.shared is not None,'lfs_shared_store_required')
|
|
204
|
+
if not self.shared.exists():return (0,0)
|
|
205
|
+
removed=bytes_removed=0
|
|
206
|
+
with self._locked():
|
|
207
|
+
for path in self.shared.iterdir():
|
|
208
|
+
if not re.fullmatch(r'[0-9a-f]{64}',path.name):continue
|
|
209
|
+
info=path.lstat()
|
|
210
|
+
require(stat.S_ISREG(info.st_mode),'lfs_object_conflict')
|
|
211
|
+
if info.st_nlink!=1 or info.st_mtime>older_than or (index/path.name).exists():
|
|
212
|
+
continue
|
|
213
|
+
path.unlink()
|
|
214
|
+
removed+=1;bytes_removed+=info.st_size
|
|
215
|
+
if removed:self._sync(self.shared)
|
|
216
|
+
return removed,bytes_removed
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class GitContentStore:
|
|
220
|
+
def __init__(self,path: Path, *, binary_dir: Path | None = None,
|
|
221
|
+
staging_dir: Path | None = None, group_read: bool = False):
|
|
222
|
+
require(type(group_read) is bool,'invalid_content_group_read')
|
|
223
|
+
self.group_read=group_read
|
|
224
|
+
self.path=Path(path)
|
|
225
|
+
self.repo=self.path/'private.git'
|
|
226
|
+
self.index=self.path/'index'
|
|
227
|
+
self.binary=Path(binary_dir) if binary_dir is not None else self.path/'binary'
|
|
228
|
+
self.staging=Path(staging_dir) if staging_dir is not None else self.path/'staging'
|
|
229
|
+
self.env={"PATH":os.environ.get('PATH','/usr/bin:/bin'),"LANG":"C.UTF-8",
|
|
230
|
+
"HOME":str(self.path),"GIT_CONFIG_NOSYSTEM":"1","GIT_CONFIG_GLOBAL":"/dev/null",
|
|
231
|
+
"GIT_AUTHOR_NAME":"msg","GIT_AUTHOR_EMAIL":"msg@localhost",
|
|
232
|
+
"GIT_COMMITTER_NAME":"msg","GIT_COMMITTER_EMAIL":"msg@localhost"}
|
|
233
|
+
# An existing installation needs an explicit offline permission review.
|
|
234
|
+
# Validate before creating/chmod'ing anything, including staging paths.
|
|
235
|
+
if group_read:
|
|
236
|
+
for p in (self.path,self.index,self.binary):
|
|
237
|
+
if p.exists() or p.is_symlink():
|
|
238
|
+
require(p.is_dir() and not p.is_symlink() and
|
|
239
|
+
p.stat().st_mode & 0o2077 == 0o2050,
|
|
240
|
+
'content_sharing_not_prepared')
|
|
241
|
+
if self.repo.exists() or self.repo.is_symlink():
|
|
242
|
+
require(self.repo.is_dir() and not self.repo.is_symlink(),
|
|
243
|
+
'content_sharing_not_prepared')
|
|
244
|
+
config=subprocess.run(['git','--git-dir',str(self.repo),'config',
|
|
245
|
+
'--local','core.sharedRepository'],capture_output=True,env=self.env,timeout=30)
|
|
246
|
+
require(config.returncode==0 and config.stdout.strip()==b'0640',
|
|
247
|
+
'content_sharing_not_prepared')
|
|
248
|
+
for p in (self.path,self.index,self.binary):
|
|
249
|
+
existed=p.exists()
|
|
250
|
+
p.mkdir(parents=True,exist_ok=True)
|
|
251
|
+
if group_read and not existed:
|
|
252
|
+
p.chmod(0o2750)
|
|
253
|
+
self.staging.mkdir(parents=True,exist_ok=True)
|
|
254
|
+
if not self.repo.exists():
|
|
255
|
+
shared=['--shared=0640'] if group_read else []
|
|
256
|
+
subprocess.run(['git','init','--bare',*shared,str(self.repo)],
|
|
257
|
+
check=True,capture_output=True,env=self.env,timeout=30)
|
|
258
|
+
|
|
259
|
+
def _run(self,*args,input=None,stdin=None):
|
|
260
|
+
result=subprocess.run(['git','--git-dir',str(self.repo),'-c','core.fsync=all',
|
|
261
|
+
'-c','core.logAllRefUpdates=false',*args],
|
|
262
|
+
input=input,stdin=stdin,capture_output=True,env=self.env,timeout=120)
|
|
263
|
+
if result.returncode:
|
|
264
|
+
raise Failure('content_store_error')
|
|
265
|
+
return result.stdout.strip().decode('ascii')
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _key(ref):
|
|
269
|
+
value=ref.digest if isinstance(ref,BlobRef) else ref
|
|
270
|
+
require(type(value) is str and re.fullmatch(r'sha256:[0-9a-f]{64}',value) is not None,'invalid_digest')
|
|
271
|
+
return value[7:]
|
|
272
|
+
|
|
273
|
+
def _entry(self,ref):
|
|
274
|
+
path=self.index/self._key(ref)
|
|
275
|
+
require(path.is_file(),'content_missing')
|
|
276
|
+
return loads(path.read_bytes())
|
|
277
|
+
|
|
278
|
+
async def put(self,chunks,media_type,expected_digest=None):
|
|
279
|
+
hasher=hashlib.sha256()
|
|
280
|
+
size=0
|
|
281
|
+
fd,name=tempfile.mkstemp(dir=self.staging)
|
|
282
|
+
try:
|
|
283
|
+
with os.fdopen(fd,'wb') as stream:
|
|
284
|
+
async for chunk in chunks:
|
|
285
|
+
require(isinstance(chunk,bytes),'invalid_chunk')
|
|
286
|
+
hasher.update(chunk)
|
|
287
|
+
size+=len(chunk)
|
|
288
|
+
stream.write(chunk)
|
|
289
|
+
stream.flush()
|
|
290
|
+
os.fsync(stream.fileno())
|
|
291
|
+
hashed='sha256:'+hasher.hexdigest()
|
|
292
|
+
if expected_digest is not None:
|
|
293
|
+
require(expected_digest==hashed,'digest_mismatch')
|
|
294
|
+
ref=BlobRef(digest=hashed,size=size,media_type=media_type)
|
|
295
|
+
is_text=media_type.startswith('text/') or media_type in {'application/json','application/msg-template'}
|
|
296
|
+
if is_text:
|
|
297
|
+
def save_git():
|
|
298
|
+
with open(name,'rb') as source:
|
|
299
|
+
oid=self._run('hash-object','-w','--stdin',stdin=source)
|
|
300
|
+
self._run('update-ref','refs/staging/'+self._key(ref),oid)
|
|
301
|
+
return oid
|
|
302
|
+
oid=await asyncio.to_thread(save_git)
|
|
303
|
+
entry={'kind':'git','oid':oid,'size':size}
|
|
304
|
+
else:
|
|
305
|
+
destination=self.binary/self._key(ref)
|
|
306
|
+
if not destination.exists():
|
|
307
|
+
if self.group_read:
|
|
308
|
+
# Rename retains the staging GID, so explicitly use the
|
|
309
|
+
# prepared destination group. Staging itself stays private.
|
|
310
|
+
os.chown(name,-1,self.binary.stat().st_gid)
|
|
311
|
+
os.chmod(name,0o640)
|
|
312
|
+
with open(name,'rb') as staged:
|
|
313
|
+
os.fsync(staged.fileno())
|
|
314
|
+
os.replace(name,destination)
|
|
315
|
+
directory=os.open(destination.parent,os.O_RDONLY|os.O_DIRECTORY)
|
|
316
|
+
try:
|
|
317
|
+
os.fsync(directory)
|
|
318
|
+
finally:
|
|
319
|
+
os.close(directory)
|
|
320
|
+
entry={'kind':'binary','size':size}
|
|
321
|
+
durable_write(self.index/self._key(ref),canonical(entry),
|
|
322
|
+
mode=0o640 if self.group_read else 0o600)
|
|
323
|
+
return ref
|
|
324
|
+
finally:
|
|
325
|
+
if os.path.exists(name):
|
|
326
|
+
os.unlink(name)
|
|
327
|
+
|
|
328
|
+
async def put_bytes(self,data,media_type='text/markdown'):
|
|
329
|
+
async def parts():
|
|
330
|
+
yield data
|
|
331
|
+
return await self.put(parts(),media_type)
|
|
332
|
+
|
|
333
|
+
async def read(self,blob,byte_range=None):
|
|
334
|
+
entry=self._entry(blob)
|
|
335
|
+
start,end=byte_range if byte_range is not None else (0,blob.size)
|
|
336
|
+
require(type(start) is int and type(end) is int and 0<=start<=end<=blob.size,'invalid_byte_range')
|
|
337
|
+
require(entry['size']==blob.size,'content_size_mismatch')
|
|
338
|
+
if entry['kind']=='binary':
|
|
339
|
+
with (self.binary/self._key(blob)).open('rb') as stream:
|
|
340
|
+
stream.seek(start)
|
|
341
|
+
remaining=end-start
|
|
342
|
+
while remaining:
|
|
343
|
+
data=stream.read(min(65536,remaining))
|
|
344
|
+
require(bool(data),'content_truncated')
|
|
345
|
+
remaining-=len(data)
|
|
346
|
+
yield data
|
|
347
|
+
return
|
|
348
|
+
process=await asyncio.create_subprocess_exec('git','--git-dir',str(self.repo),'cat-file','blob',entry['oid'],
|
|
349
|
+
stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.DEVNULL,env=self.env)
|
|
350
|
+
position=0
|
|
351
|
+
try:
|
|
352
|
+
while data:=await process.stdout.read(65536):
|
|
353
|
+
stop=position+len(data)
|
|
354
|
+
if position<end and stop>start:
|
|
355
|
+
yield data[max(0,start-position):min(len(data),end-position)]
|
|
356
|
+
position=stop
|
|
357
|
+
require(await process.wait()==0 and position==blob.size,'content_truncated')
|
|
358
|
+
finally:
|
|
359
|
+
if process.returncode is None:
|
|
360
|
+
process.kill()
|
|
361
|
+
await process.wait()
|
|
362
|
+
|
|
363
|
+
async def read_bytes(self,blob, *, limit=1024*1024):
|
|
364
|
+
require(blob.size<=limit,'use_transfer')
|
|
365
|
+
return b''.join([chunk async for chunk in self.read(blob)])
|
|
366
|
+
|
|
367
|
+
async def _update_pin_ref(self, *args):
|
|
368
|
+
# Cancelling to_thread does not stop Git. Finish the mutation before the
|
|
369
|
+
# metadata transaction can compensate it or release its writer fence.
|
|
370
|
+
pending = asyncio.create_task(asyncio.to_thread(self._run, *args))
|
|
371
|
+
try:
|
|
372
|
+
return await asyncio.shield(pending)
|
|
373
|
+
except asyncio.CancelledError as cancelled:
|
|
374
|
+
while not pending.done():
|
|
375
|
+
try:
|
|
376
|
+
await asyncio.shield(pending)
|
|
377
|
+
except asyncio.CancelledError:
|
|
378
|
+
pass
|
|
379
|
+
except BaseException:
|
|
380
|
+
break
|
|
381
|
+
try:
|
|
382
|
+
pending.result()
|
|
383
|
+
except BaseException as exc:
|
|
384
|
+
cancelled.add_note('pin update failed: ' + type(exc).__name__)
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
async def pin(self,blob,lease_id):
|
|
388
|
+
entry=self._entry(blob)
|
|
389
|
+
name=hashlib.sha256(lease_id.encode()).hexdigest()
|
|
390
|
+
if entry['kind']=='git':
|
|
391
|
+
await self._update_pin_ref('update-ref',f'refs/pins/{name}/{self._key(blob)}',entry['oid'])
|
|
392
|
+
else:
|
|
393
|
+
durable_write(self.path/'pins'/name/self._key(blob),b'1\n')
|
|
394
|
+
|
|
395
|
+
async def unpin(self,blob,lease_id):
|
|
396
|
+
entry=self._entry(blob)
|
|
397
|
+
name=hashlib.sha256(lease_id.encode()).hexdigest()
|
|
398
|
+
if entry['kind']=='git':
|
|
399
|
+
await self._update_pin_ref('update-ref','-d',f'refs/pins/{name}/{self._key(blob)}')
|
|
400
|
+
else:
|
|
401
|
+
(self.path/'pins'/name/self._key(blob)).unlink(missing_ok=True)
|
|
402
|
+
|
|
403
|
+
async def pinned(self,blob,lease_id):
|
|
404
|
+
entry=self._entry(blob)
|
|
405
|
+
name=hashlib.sha256(lease_id.encode()).hexdigest()
|
|
406
|
+
if entry['kind']=='binary':
|
|
407
|
+
return (self.path/'pins'/name/self._key(blob)).exists()
|
|
408
|
+
try:
|
|
409
|
+
return bool(await asyncio.to_thread(self._run,'rev-parse','--verify',f'refs/pins/{name}/{self._key(blob)}'))
|
|
410
|
+
except Failure:
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
async def commit_revision(self,topic_id,revision):
|
|
414
|
+
"""Create and protect one resource revision before the PostgreSQL pointer commits."""
|
|
415
|
+
entry=self._entry(revision.content)
|
|
416
|
+
def commit():
|
|
417
|
+
manifest=self._run('hash-object','-w','--stdin',input=canonical(revision))
|
|
418
|
+
lines=f'100644 blob {manifest}\tmanifest.json\n'
|
|
419
|
+
if entry['kind']=='git':
|
|
420
|
+
lines+=f"100644 blob {entry['oid']}\tcontent\n"
|
|
421
|
+
tree=self._run('mktree',input=lines.encode())
|
|
422
|
+
parents=[]
|
|
423
|
+
for parent in revision.parents:
|
|
424
|
+
parent_path=self.path/'revisions'/parent
|
|
425
|
+
require(parent_path.is_file(),'revision_content_missing')
|
|
426
|
+
parents+=['-p',parent_path.read_text().strip()]
|
|
427
|
+
oid=self._run('commit-tree',tree,*parents,input=(revision.id+'\n').encode())
|
|
428
|
+
# IDs are generated internally, but hash all ref components anyway.
|
|
429
|
+
topic=hashlib.sha256(topic_id.encode()).hexdigest()
|
|
430
|
+
resource=hashlib.sha256(revision.resource_id.encode()).hexdigest()
|
|
431
|
+
rev=hashlib.sha256(revision.id.encode()).hexdigest()
|
|
432
|
+
self._run('update-ref',f'refs/topics/{topic}/{resource}/{rev}',oid)
|
|
433
|
+
durable_write(self.path/'revisions'/revision.id,(oid+'\n').encode())
|
|
434
|
+
return oid
|
|
435
|
+
return await asyncio.to_thread(commit)
|