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/core/text_patch.py ADDED
@@ -0,0 +1,283 @@
1
+ """Bounded, byte-preserving text edits. No fuzzy matching or executable syntax.
2
+
3
+ A heading edit replaces its complete Markdown section, including its heading.
4
+ A block is a heading, fenced code block, or contiguous nonblank text. Empty
5
+ separator lines are not included. Digests cover the selected original UTF-8.
6
+ Unified patches describe one file; their filenames never select a resource.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import re
12
+
13
+ from msg.core.codec import digest, wire
14
+ from msg.core.errors import require
15
+
16
+ PATCH_LIMIT = 1048576
17
+ PATCH_CONTEXT_LIMIT = 256
18
+ PATCH_CANDIDATE_LIMIT = 4096
19
+ MAX_HUNKS = 256
20
+ MAX_LINES = 65536
21
+
22
+ _TEXT = {'type':'string', 'maxLength':PATCH_LIMIT}
23
+ _DIGEST = {'type':'string', 'pattern':r'^sha256:[0-9a-f]{64}$'}
24
+
25
+
26
+ def _variant(kind, properties, required):
27
+ return {'type':'object', 'properties':{'kind':{'const':kind}, **properties},
28
+ 'required':['kind', *required], 'additionalProperties':False}
29
+
30
+
31
+ PATCH_SCHEMA = {'oneOf':[
32
+ _variant('exact', {'exact':{**_TEXT, 'minLength':1}, 'replacement':_TEXT,
33
+ 'before':{'type':'string','maxLength':PATCH_CONTEXT_LIMIT},
34
+ 'after':{'type':'string','maxLength':PATCH_CONTEXT_LIMIT}}, ('exact','replacement')),
35
+ _variant('unified', {'diff':{**_TEXT, 'minLength':1}}, ('diff',)),
36
+ _variant('heading', {'heading':{'type':'string','minLength':1,'maxLength':256},
37
+ 'digest':_DIGEST, 'replacement':_TEXT}, ('heading','digest','replacement')),
38
+ _variant('block', {'digest':_DIGEST, 'replacement':_TEXT}, ('digest','replacement')),
39
+ ]}
40
+
41
+
42
+ def validate_patch(patch):
43
+ # The public registry validates the same closed schema. Keep this guard for
44
+ # direct callers and batch preparation, before any content is persisted.
45
+ from jsonschema import Draft202012Validator
46
+ require(Draft202012Validator(PATCH_SCHEMA).is_valid(wire(patch)), 'invalid_text_patch')
47
+ require(sum(len(value.encode('utf-8')) for value in patch.values()
48
+ if isinstance(value,str)) <= PATCH_LIMIT, 'patch_too_large')
49
+
50
+
51
+ def apply_text_patch(source, exact, replacement, before='', after=''):
52
+ """Legacy contextual replacement, with unchanged ambiguity semantics."""
53
+ require(bool(exact), 'patch_exact_required')
54
+ match = None
55
+ start = candidates = 0
56
+ while True:
57
+ index = source.find(exact, start)
58
+ if index < 0:
59
+ break
60
+ candidates += 1
61
+ require(candidates <= PATCH_CANDIDATE_LIMIT, 'patch_too_complex')
62
+ end = index + len(exact)
63
+ if (index >= len(before) and source.startswith(before,index-len(before))
64
+ and source.startswith(after,end)):
65
+ require(match is None, 'patch_ambiguous')
66
+ match = index
67
+ start = index + 1
68
+ require(match is not None, 'patch_no_match')
69
+ result = source[:match] + replacement + source[match+len(exact):]
70
+ require(len(result.encode('utf-8')) <= PATCH_LIMIT, 'patch_too_large')
71
+ return result
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Hunk:
76
+ old_start: int
77
+ new_start: int
78
+ old: tuple[str, ...]
79
+ new: tuple[str, ...]
80
+
81
+
82
+ _HUNK = re.compile(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:[^\r\n]*)\r?\n?\Z')
83
+ _NO_NEWLINE = '\'
84
+
85
+
86
+ def _diff_lines(text):
87
+ # Git/unified diff separates lines only at LF. str.splitlines also splits
88
+ # Unicode separators and CR, changing the signed bytes and hunk counts.
89
+ return re.findall(r'[^\n]*\n|[^\n]+$', text)
90
+
91
+
92
+ def _hunks(text):
93
+ lines = _diff_lines(text)
94
+ require(len(lines) <= MAX_LINES, 'patch_too_complex')
95
+ i = 0
96
+ if lines and lines[0].startswith('--- '):
97
+ require(len(lines)>1 and lines[1].startswith('+++ '), 'invalid_unified_patch')
98
+ i = 2
99
+ hunks = []
100
+ while i < len(lines):
101
+ match = _HUNK.fullmatch(lines[i])
102
+ require(match is not None and len(hunks)<MAX_HUNKS, 'invalid_unified_patch')
103
+ old_no, old_count, new_no, new_count = (
104
+ int(match[1]), int(match[2] or 1), int(match[3]), int(match[4] or 1))
105
+ require((not old_count or old_no>0) and (not new_count or new_no>0),
106
+ 'invalid_unified_patch')
107
+ i += 1
108
+ old, new = [], []
109
+ previous = None
110
+ while i < len(lines) and not lines[i].startswith('@@ '):
111
+ line = lines[i]
112
+ if line.rstrip('\r\n') == _NO_NEWLINE:
113
+ require(previous is not None, 'invalid_unified_patch')
114
+ for side in previous:
115
+ require(side[-1].endswith('\n'), 'invalid_unified_patch')
116
+ side[-1] = side[-1][:-1]
117
+ previous = None
118
+ else:
119
+ require(line[:1] in {' ','-','+'}, 'invalid_unified_patch')
120
+ # Once counts are exhausted, another file header is not a hunk.
121
+ require(len(old)<old_count or len(new)<new_count, 'invalid_unified_patch')
122
+ previous = []
123
+ if line[0] in ' -':
124
+ old.append(line[1:]); previous.append(old)
125
+ if line[0] in ' +':
126
+ new.append(line[1:]); previous.append(new)
127
+ i += 1
128
+ require(len(old)==old_count and len(new)==new_count, 'invalid_unified_patch')
129
+ hunks.append(Hunk(old_no-1 if old_count else old_no,
130
+ new_no-1 if new_count else new_no, tuple(old), tuple(new)))
131
+ require(bool(hunks), 'invalid_unified_patch')
132
+ return hunks
133
+
134
+
135
+ def _unified(source, hunks, *, rebase=False):
136
+ lines = _diff_lines(source)
137
+ require(len(lines) <= MAX_LINES, 'patch_too_complex')
138
+ require(not rebase or len(lines)*len(hunks)<=1000000, 'patch_too_complex')
139
+ result = []
140
+ last = delta = 0
141
+ for hunk in hunks:
142
+ start = hunk.old_start
143
+ if rebase:
144
+ # Insertions without an unchanged context have no safe locator.
145
+ require(bool(hunk.old), 'patch_rebase_unsupported')
146
+ # Exact sequence search in linear time (KMP), not quadratic scanning
147
+ # of a large context at every repeated line.
148
+ starts = _sequence_matches(lines,hunk.old)
149
+ require(bool(starts), 'patch_no_match')
150
+ require(len(starts)==1, 'patch_ambiguous')
151
+ start = starts[0]
152
+ else:
153
+ require(hunk.new_start==hunk.old_start+delta, 'invalid_unified_patch')
154
+ require(last<=start<=len(lines), 'invalid_unified_patch')
155
+ end = start+len(hunk.old)
156
+ require(tuple(lines[start:end])==hunk.old, 'patch_no_match')
157
+ result.extend(lines[last:start]); result.extend(hunk.new)
158
+ last = end
159
+ delta += len(hunk.new)-len(hunk.old)
160
+ result.extend(lines[last:])
161
+ return ''.join(result)
162
+
163
+
164
+ def _sequence_matches(lines, needle):
165
+ prefix = [0]*len(needle)
166
+ j = 0
167
+ for i in range(1,len(needle)):
168
+ while j and needle[i]!=needle[j]:
169
+ j = prefix[j-1]
170
+ if needle[i]==needle[j]:
171
+ j += 1
172
+ prefix[i] = j
173
+ result = []
174
+ j = 0
175
+ for i,line in enumerate(lines):
176
+ while j and line!=needle[j]:
177
+ j = prefix[j-1]
178
+ if line==needle[j]:
179
+ j += 1
180
+ if j==len(needle):
181
+ result.append(i-j+1)
182
+ if len(result)==2:
183
+ return result
184
+ j = prefix[j-1]
185
+ return result
186
+
187
+
188
+ _ATX = re.compile(r'^ {0,3}(#{1,6})(?:[ \t]+(.*?)|[ \t]*)$')
189
+ _FENCE = re.compile(r'^ {0,3}(`{3,}|~{3,})(.*)$')
190
+ _SETEXT = re.compile(r'^ {0,3}(=+|-+)[ \t]*$')
191
+
192
+
193
+ def _markdown(source):
194
+ """Locate raw spans; this is intentionally not a semantic Markdown AST."""
195
+ # Markdown admits CR/LF/CRLF, not Unicode paragraph/line separators.
196
+ lines = re.findall(r'[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$', source)
197
+ require(len(lines)<=MAX_LINES, 'patch_too_complex')
198
+ offsets = [0]
199
+ for line in lines:
200
+ offsets.append(offsets[-1]+len(line))
201
+ headings, blocks = [], []
202
+ i = 0
203
+ while i<len(lines):
204
+ text = lines[i].rstrip('\r\n')
205
+ if not text.strip():
206
+ i += 1
207
+ continue
208
+ fence = _FENCE.match(text)
209
+ if fence and not (fence[1][0]=='`' and '`' in fence[2]):
210
+ start = i
211
+ marker = fence[1]
212
+ i += 1
213
+ while i<len(lines):
214
+ end = _FENCE.match(lines[i].rstrip('\r\n'))
215
+ i += 1
216
+ if end and end[1][0]==marker[0] and len(end[1])>=len(marker) and not end[2].strip():
217
+ break
218
+ blocks.append((offsets[start],offsets[i]))
219
+ continue
220
+ atx = _ATX.match(text)
221
+ setext = _SETEXT.match(lines[i+1].rstrip('\r\n')) if i+1<len(lines) else None
222
+ # Four-space-indented code is not a setext heading.
223
+ if atx or (setext and not text.startswith((' ','\t'))):
224
+ level = len(atx[1]) if atx else (1 if setext[1][0]=='=' else 2)
225
+ title = (atx[2] or '').strip() if atx else text.strip()
226
+ if atx:
227
+ title = re.sub(r'[ \t]+#+[ \t]*$','',title).rstrip()
228
+ start = i
229
+ i += 1 if atx else 2
230
+ headings.append((title,level,offsets[start]))
231
+ blocks.append((offsets[start],offsets[i]))
232
+ continue
233
+ start = i
234
+ i += 1
235
+ while i<len(lines) and lines[i].strip():
236
+ upcoming = lines[i].rstrip('\r\n')
237
+ underline = _SETEXT.match(lines[i+1].rstrip('\r\n')) if i+1<len(lines) else None
238
+ if _ATX.match(upcoming) or _FENCE.match(upcoming) or underline:
239
+ break
240
+ i += 1
241
+ blocks.append((offsets[start],offsets[i]))
242
+ require(len(blocks)<=PATCH_CANDIDATE_LIMIT, 'patch_too_complex')
243
+ return headings,blocks
244
+
245
+
246
+ def _structured(source, patch):
247
+ headings, blocks = _markdown(source)
248
+ if patch['kind']=='heading':
249
+ matches = [i for i,(title,_,_) in enumerate(headings) if title==patch['heading']]
250
+ require(bool(matches), 'patch_no_match')
251
+ require(len(matches)==1, 'patch_ambiguous')
252
+ index = matches[0]
253
+ _, level, start = headings[index]
254
+ end = next((pos for _,depth,pos in headings[index+1:] if depth<=level),len(source))
255
+ matches = [(start,end)]
256
+ else:
257
+ matches = [(start,end) for start,end in blocks
258
+ if digest(source[start:end].encode('utf-8'))==patch['digest']]
259
+ require(bool(matches), 'patch_no_match')
260
+ require(len(matches)==1, 'patch_ambiguous')
261
+ start,end = matches[0]
262
+ require(digest(source[start:end].encode('utf-8'))==patch['digest'], 'patch_no_match')
263
+ return source[:start]+patch['replacement']+source[end:]
264
+
265
+
266
+ def apply_patch(source, patch, *, base_source=None):
267
+ validate_patch(patch)
268
+ require(len(source.encode('utf-8'))<=PATCH_LIMIT, 'patch_too_large')
269
+ if base_source is not None:
270
+ require(len(base_source.encode('utf-8'))<=PATCH_LIMIT, 'patch_too_large')
271
+ # Base validation is independent of current text: no forged old anchor
272
+ # can become a successful edit merely because it matches the new text.
273
+ apply_patch(base_source,patch)
274
+ kind = patch['kind']
275
+ if kind=='exact':
276
+ result = apply_text_patch(source,patch['exact'],patch['replacement'],
277
+ patch.get('before',''),patch.get('after',''))
278
+ elif kind=='unified':
279
+ result = _unified(source,_hunks(patch['diff']),rebase=base_source is not None)
280
+ else:
281
+ result = _structured(source,patch)
282
+ require(len(result.encode('utf-8'))<=PATCH_LIMIT, 'patch_too_large')
283
+ return result
@@ -0,0 +1,28 @@
1
+ """Local tool execution port, independent of worker orchestration and backends.
2
+
3
+ A runner returns a local artifact, never a committed ResourceRef. The worker
4
+ validates it, rechecks current authority/attempt/deadline and publishes it in its
5
+ existing transaction. Implementing this Protocol grants no execution authority.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from .models import JsonMap, NetworkPolicy, ToolSpec
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ToolResult:
18
+ path: Path
19
+ media_type: str
20
+ metadata: dict
21
+
22
+
23
+ @runtime_checkable
24
+ class ToolRunner(Protocol):
25
+ async def __call__(
26
+ self, tool: ToolSpec, arguments: JsonMap,
27
+ policies: tuple[NetworkPolicy, ...], directory: Path, /
28
+ ) -> ToolResult: ...
msg/core/transfer.py ADDED
@@ -0,0 +1,183 @@
1
+ """Persistent, owner-bound byte-range transfers with explicit publication points."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from datetime import timedelta
6
+ import hashlib
7
+ import re
8
+ from uuid import uuid4
9
+
10
+ from msg.core.codec import canonical,decode,digest,loads,wire
11
+ from msg.core.errors import Failure,require
12
+ from msg.core.models import TransferSession,TransferChunk,ResourceRef,AccessRequirement,Page
13
+ from msg.storage.capacity import require_transfer_capacity
14
+
15
+
16
+ def validate_digest(value):
17
+ require(isinstance(value,str) and re.fullmatch(r'sha256:[0-9a-f]{64}',value) is not None,'invalid_digest')
18
+ return value
19
+
20
+
21
+ class TransferService:
22
+ def __init__(self,metadata,contents,authorizer,clock, *, publish,ttl=86400,part_bytes=65536):
23
+ self.metadata,self.contents,self.authorizer,self.clock=metadata,contents,authorizer,clock
24
+ self.publish,self.ttl,self.part_bytes=publish,ttl,part_bytes
25
+
26
+ async def _access(self,context,tx,resource,operation,check):
27
+ await self.authorizer.require(context,None,(AccessRequirement(resource_id=resource,
28
+ operation=operation+'@1',check=check),),tx)
29
+
30
+ async def _session(self,context,tx,id,operation, *, allow_cancelled=False):
31
+ transfer=await tx.transfer(id)
32
+ require(transfer.subject_id==context.principal.subject,'transfer_owner_required')
33
+ require(transfer.expires_at>self.clock(),'transfer_expired')
34
+ require(allow_cancelled or transfer.state not in {'cancelled','expired'},'transfer_closed')
35
+ # Each continuation rechecks the current permission and credential ceiling,
36
+ # not just a cached decision from open().
37
+ check='create' if transfer.direction=='upload' else 'read'
38
+ await self._access(context,tx,transfer.target.id,operation,check)
39
+ return transfer
40
+
41
+ def _limits(self,tx,id):
42
+ return loads(tx.one('SELECT limits FROM transfers WHERE id=?',(id,))[0])
43
+
44
+ async def open(self,context,direction,target,size,digest,expires_at, *, limits=None,media_type='application/octet-stream'):
45
+ require(context.principal.subject is not None,'authentication_required')
46
+ require(direction in {'upload','download'},'invalid_direction')
47
+ require(self.clock()<expires_at<=self.clock()+timedelta(seconds=self.ttl),'invalid_transfer_expiry')
48
+ require(size is None or type(size) is int and 0<=size<2**63,'invalid_size')
49
+ if digest is not None:
50
+ validate_digest(digest)
51
+ require(isinstance(media_type,str) and 0<len(media_type)<=255 and '\r' not in media_type and '\n' not in media_type,
52
+ 'invalid_media_type')
53
+ async with self.metadata.transaction(write=True) as tx:
54
+ require_transfer_capacity(tx, new_transfer=True)
55
+ if direction=='download':
56
+ require(target is not None,'target_required')
57
+ await self._access(context,tx,target.id,'transfer.open','read')
58
+ revision=await tx.revision(target)
59
+ target=ResourceRef(id=target.id,revision=revision.id)
60
+ size,digest=revision.content.size,revision.content.digest
61
+ media_type=revision.content.media_type
62
+ else:
63
+ if target is None:
64
+ row=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',(context.principal.subject,'files'))
65
+ require(row is not None,'upload_parent_required')
66
+ target=ResourceRef(id=row[0])
67
+ require(target.revision is None,'upload_target_must_be_container')
68
+ parent=await tx.resource(target.id)
69
+ require(parent.type in {'topic','user','organization'},'not_a_container')
70
+ await self._access(context,tx,target.id,'transfer.open','create')
71
+ transfer=TransferSession(id='tr_'+uuid4().hex,subject_id=context.principal.subject,direction=direction,
72
+ state='open',target=target,expected_size=size,expected_digest=digest,expires_at=expires_at,generation=0)
73
+ await tx.save_transfer(transfer,None)
74
+ options=dict(limits or {},media_type=media_type)
75
+ options.setdefault('part_bytes',self.part_bytes)
76
+ tx.execute('UPDATE transfers SET limits=? WHERE id=?',(canonical(options).decode(),transfer.id),write=True)
77
+ return transfer
78
+
79
+ async def put(self,context,transfer_id,offset,data,digest):
80
+ validate_digest(digest)
81
+ require(type(offset) is int and 0<=offset<2**63,'invalid_offset')
82
+ require(isinstance(data,bytes) and data,'empty_chunk')
83
+ require('sha256:'+hashlib.sha256(data).hexdigest()==digest,'chunk_digest_mismatch')
84
+ async with self.metadata.transaction(write=True) as tx:
85
+ transfer=await self._session(context,tx,transfer_id,'transfer.part_put')
86
+ require(transfer.direction=='upload','wrong_transfer_direction')
87
+ limits=self._limits(tx,transfer_id)
88
+ require(len(data)<=limits['part_bytes'],'part_too_large')
89
+ require(offset+len(data)<2**63,'invalid_offset')
90
+ require(transfer.expected_size is None or offset+len(data)<=transfer.expected_size,'part_out_of_bounds')
91
+ # Equality includes the range and bytes' digest, not their arrival order.
92
+ rows=tx.rows('SELECT offset,length,body FROM chunks WHERE transfer_id=? AND offset<? AND offset+length>?',
93
+ (transfer_id,offset+len(data),offset))
94
+ if rows:
95
+ require(len(rows)==1 and rows[0][0]==offset and rows[0][1]==len(data) and
96
+ decode(TransferChunk,loads(rows[0][2])).content.digest==digest,'chunk_conflict')
97
+ return decode(TransferChunk,loads(rows[0][2]))
98
+ require(transfer.state=='open','transfer_closed')
99
+ require_transfer_capacity(tx, len(data))
100
+ async def pieces():
101
+ yield data
102
+ blob=await self.contents.put(pieces(),'application/octet-stream',expected_digest=digest)
103
+ lease=transfer_id+':'+str(offset)
104
+ tx.on_rollback(lambda: self.contents.unpin(blob,lease))
105
+ await self.contents.pin(blob,lease)
106
+ chunk=TransferChunk(transfer_id=transfer_id,offset=offset,content=blob)
107
+ await tx.put_chunk(chunk)
108
+ await tx.save_transfer(replace(transfer,generation=transfer.generation+1),transfer.generation)
109
+ return chunk
110
+
111
+ async def get(self,context,transfer_id,byte_range):
112
+ require(isinstance(byte_range,tuple) and len(byte_range)==2 and
113
+ all(type(i) is int for i in byte_range),'invalid_byte_range')
114
+ start,end=byte_range
115
+ async with self.metadata.transaction(write=False) as tx:
116
+ transfer=await self._session(context,tx,transfer_id,'transfer.part_get')
117
+ require(transfer.direction=='download','wrong_transfer_direction')
118
+ require(0<=start<=end<=transfer.expected_size,'part_out_of_bounds')
119
+ limits=self._limits(tx,transfer_id)
120
+ require(end-start<=limits['part_bytes'],'part_too_large')
121
+ revision=await tx.revision(transfer.target)
122
+ data=b''.join([part async for part in self.contents.read(revision.content,(start,end))])
123
+ from msg.core.models import BlobRef
124
+ chunk=TransferChunk(transfer_id=transfer_id,offset=start,
125
+ content=BlobRef(digest=digest(data),size=len(data),media_type=revision.content.media_type))
126
+ return chunk,data
127
+
128
+ async def status(self,context,transfer_id,cursor=None,limit=50):
129
+ async with self.metadata.transaction(write=False) as tx:
130
+ transfer=await self._session(context,tx,transfer_id,'transfer.status',allow_cancelled=True)
131
+ if transfer.direction=='download' or transfer.state=='sealed':
132
+ return Page(items=())
133
+ if transfer.expected_size is None:
134
+ # An unknown final length cannot be described as a closed missing
135
+ # interval. Expose received ranges through the operation projection.
136
+ return Page(items=())
137
+ return await tx.missing_ranges(transfer_id,cursor,limit)
138
+
139
+ async def seal(self,context,transfer_id,final_size,final_digest):
140
+ validate_digest(final_digest)
141
+ require(type(final_size) is int and 0<=final_size<2**63,'invalid_size')
142
+ async with self.metadata.transaction(write=True) as tx:
143
+ transfer=await self._session(context,tx,transfer_id,'transfer.seal')
144
+ require(transfer.expected_size is None or final_size==transfer.expected_size,'final_size_mismatch')
145
+ require(transfer.expected_digest is None or final_digest==transfer.expected_digest,'final_digest_mismatch')
146
+ if transfer.state=='sealed':
147
+ require(transfer.expected_size==final_size and transfer.expected_digest==final_digest,'seal_conflict')
148
+ return transfer.output
149
+ require(transfer.state=='open','transfer_closed')
150
+ if transfer.direction=='download':
151
+ output=transfer.target
152
+ else:
153
+ position=0
154
+ # Rows are streamed from SQLite, not collected into an unbounded list.
155
+ for start,length in tx.execute('SELECT offset,length FROM chunks WHERE transfer_id=? ORDER BY offset',(transfer_id,)):
156
+ require(start==position,'transfer_incomplete')
157
+ position=start+length
158
+ require(position==final_size,'transfer_incomplete')
159
+ async def pieces():
160
+ for (body,) in tx.execute('SELECT body FROM chunks WHERE transfer_id=? ORDER BY offset',(transfer_id,)):
161
+ chunk=decode(TransferChunk,loads(body))
162
+ async for data in self.contents.read(chunk.content):
163
+ yield data
164
+ limits=self._limits(tx,transfer_id)
165
+ blob=await self.contents.put(pieces(),limits['media_type'],expected_digest=final_digest)
166
+ require(blob.size==final_size,'final_size_mismatch')
167
+ tx.on_rollback(lambda: self.contents.unpin(blob,transfer_id+':sealed'))
168
+ await self.contents.pin(blob,transfer_id+':sealed')
169
+ output=await self.publish(context,tx,transfer.target.id,blob)
170
+ await tx.save_transfer(replace(transfer,state='sealed',expected_size=final_size,expected_digest=final_digest,
171
+ output=output,generation=transfer.generation+1),transfer.generation)
172
+ return output
173
+
174
+ async def cancel(self,context,transfer_id):
175
+ async with self.metadata.transaction(write=True) as tx:
176
+ transfer=await self._session(context,tx,transfer_id,'transfer.cancel',allow_cancelled=True)
177
+ if transfer.state=='cancelled':
178
+ return
179
+ require(transfer.state=='open','transfer_closed')
180
+ await tx.save_transfer(replace(transfer,state='cancelled',generation=transfer.generation+1),transfer.generation)
181
+ # Physical chunk cleanup is separate from this SQL transaction. Its
182
+ # failure cannot invalidate a cancellation that has already committed.
183
+ tx.set_setting('transfer_cleanup:'+transfer.id,{'state':'pending','after':wire(self.clock())})