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
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)