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/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())})
|