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
@@ -0,0 +1,281 @@
1
+ """Local recovery's deny-only replay, never a promotion or trust bootstrap.
2
+
3
+ A pin MUST arrive through an independently authenticated operator channel outside
4
+ this database and its backup/rollback set. Neither a database watermark nor an
5
+ audit hash chain supplies that pin. There is deliberately no default trust key,
6
+ network operation, configuration override, or quarantine-clearing operation here.
7
+ Unknown fact kinds require a newer reviewed replay implementation and stay blocked.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, replace
13
+ from datetime import UTC, datetime
14
+ from uuid import uuid4
15
+
16
+ from msg.constants import ROOT_SUBJECT
17
+ from msg.core.codec import canonical, decode, digest, loads, parse_time, wire
18
+ from msg.core.errors import Failure, require
19
+ from msg.core.models import AuditEvent, Certificate, Credential, Event, Membership, Signature
20
+ from msg.security.crypto import key_id, verify
21
+ from msg.security.quarantine import SETTING, active
22
+
23
+ FORMAT = 'msg-revocation-checkpoint-v1'
24
+ SUPPORTED_FACTS = frozenset({'credential.revoke', 'certificate.revoke', 'share_grant.revoke',
25
+ 'share_grant_v2.revoke', 'share_link.revoke', 'membership.remove',
26
+ 'topic_membership.remove', 'topic_ban.lift', 'identity_key.retire',
27
+ 'encryption_key.retire', 'vault.destroy'})
28
+ _SHARE_TABLES = {'share_grant.revoke': 'share_grants', 'share_grant_v2.revoke': 'share_grants_v2',
29
+ 'share_link.revoke': 'share_links'}
30
+ _MAX_ENTRIES = 10000
31
+
32
+
33
+ @dataclass(frozen=True, slots=True, kw_only=True)
34
+ class TrustedCheckpointPin:
35
+ """Independently approved exact head, not a value extracted from the packet.
36
+
37
+ Creating this internal value is the trusted local caller's responsibility.
38
+ It does not assert that an offline source was current or operator-approved.
39
+ Production remains blocked without evidence for that separate trust ceremony.
40
+ """
41
+ service: str
42
+ public_key: bytes
43
+ digest: str
44
+ sequence: int
45
+
46
+
47
+ def verify_checkpoint(packet, *, pin):
48
+ require(isinstance(pin, TrustedCheckpointPin), 'recovery_checkpoint_pin_required')
49
+ try:
50
+ require(type(packet) is dict and set(packet) == {'checkpoint', 'signature'},
51
+ 'recovery_checkpoint_invalid')
52
+ body = packet['checkpoint']
53
+ require(type(body) is dict and set(body) == {
54
+ 'format', 'service', 'source_backup_sha256', 'sequence', 'entries'},
55
+ 'recovery_checkpoint_invalid')
56
+ require(type(pin.public_key) is bytes and len(pin.public_key) == 32 and
57
+ type(pin.sequence) is int and 0 <= pin.sequence <= _MAX_ENTRIES and
58
+ type(body['sequence']) is int and body['sequence'] == pin.sequence and
59
+ body['format'] == FORMAT and body['service'] == pin.service and
60
+ isinstance(pin.service, str) and bool(pin.service) and
61
+ isinstance(body['source_backup_sha256'], str) and
62
+ re.fullmatch('[0-9a-f]{64}', body['source_backup_sha256']) is not None and
63
+ digest(body) == pin.digest, 'recovery_checkpoint_invalid')
64
+ entries = body['entries']
65
+ require(type(entries) is list and len(entries) == pin.sequence,
66
+ 'recovery_checkpoint_invalid')
67
+ for sequence, fact in enumerate(entries, 1):
68
+ require(type(fact) is dict and set(fact) == {
69
+ 'sequence', 'kind', 'subject', 'target', 'at'} and
70
+ type(fact['sequence']) is int and fact['sequence'] == sequence and
71
+ fact['kind'] in SUPPORTED_FACTS and
72
+ all(isinstance(fact[name], str) and 0 < len(fact[name]) <= 256
73
+ for name in ('subject', 'target')), 'recovery_checkpoint_invalid')
74
+ require(parse_time(fact['at']) <= datetime.now(UTC), 'recovery_checkpoint_invalid')
75
+ verify(pin.public_key, canonical(body), decode(Signature, packet['signature']),
76
+ purpose='recovery-checkpoint-v1')
77
+ # Own the verified bytes across the subsequent await/transaction boundary.
78
+ return loads(canonical(body))
79
+ except (Failure, KeyError, TypeError, ValueError, OverflowError) as exc:
80
+ raise Failure('recovery_checkpoint_invalid') from exc
81
+
82
+
83
+ def _owned(row, subject):
84
+ require(row is not None, 'recovery_fact_missing')
85
+ require(row[0] == subject, 'recovery_fact_subject_mismatch')
86
+ return row
87
+
88
+
89
+ def _earlier(previous, at):
90
+ return at if previous is None or parse_time(at) < parse_time(previous) else previous
91
+
92
+
93
+ def _revoke_credential(tx, subject, target, at):
94
+ row = _owned(tx.one('SELECT subject,body FROM credentials WHERE id=?', (target,)), subject)
95
+ credential = decode(Credential, loads(row[1]))
96
+ require(credential.id == target and credential.subject_id == subject,
97
+ 'recovery_fact_subject_mismatch')
98
+ when = parse_time(at)
99
+ changed = credential.revoked_at is None or credential.revoked_at > when
100
+ if changed:
101
+ revoked = replace(credential, revoked_at=when)
102
+ tx.execute('UPDATE credentials SET body=? WHERE id=?',
103
+ (canonical(revoked).decode(), target), write=True)
104
+ if credential.kind == 'token':
105
+ delivery = tx.one('SELECT subject,consumed_at FROM token_deliveries WHERE credential_id=?',
106
+ (target,))
107
+ if delivery is not None:
108
+ _owned(delivery, subject)
109
+ earliest = _earlier(delivery[1], at)
110
+ if earliest != delivery[1]:
111
+ tx.execute('UPDATE token_deliveries SET consumed_at=? WHERE credential_id=?',
112
+ (earliest, target), write=True)
113
+ changed = True
114
+ return changed
115
+
116
+
117
+ async def _apply(tx, fact):
118
+ subject, target, at, kind = (fact[name] for name in ('subject', 'target', 'at', 'kind'))
119
+ if kind == 'credential.revoke':
120
+ return _revoke_credential(tx, subject, target, at)
121
+ if kind == 'certificate.revoke':
122
+ row = _owned(tx.one('SELECT subject,revoked,body FROM certificates WHERE id=?',
123
+ (target,)), subject)
124
+ certificate = decode(Certificate, loads(row[2]))
125
+ require(certificate.resource_id == target and certificate.subject_id == subject,
126
+ 'recovery_fact_subject_mismatch')
127
+ if row[1] != 1:
128
+ tx.execute('UPDATE certificates SET revoked=1 WHERE id=?', (target,), write=True)
129
+ return True
130
+ return False
131
+ if kind in _SHARE_TABLES:
132
+ table = _SHARE_TABLES[kind] # Fixed closed vocabulary, never caller SQL.
133
+ row = _owned(tx.one(f'SELECT grantor,revoked_at FROM {table} WHERE id=?', (target,)), subject)
134
+ earliest = _earlier(row[1], at)
135
+ if row[1] != earliest:
136
+ tx.execute(f'UPDATE {table} SET revoked_at=? WHERE id=?', (earliest, target), write=True)
137
+ return True
138
+ return False
139
+ if kind == 'membership.remove':
140
+ require(target != 'g_public', 'recovery_virtual_membership_requires_identity_policy')
141
+ row = tx.one('SELECT generation,body FROM memberships WHERE org=? AND subject=?',
142
+ (target, subject))
143
+ require(row is not None, 'recovery_fact_missing')
144
+ member = decode(Membership, loads(row[1]))
145
+ require(member.subject_id == subject and member.organization_id == target and
146
+ member.version == row[0], 'recovery_fact_subject_mismatch')
147
+ if member.status != 'rejected':
148
+ await tx.update_identity(replace(member, status='rejected', version=member.version + 1),
149
+ member.version)
150
+ return True
151
+ return False
152
+ if kind == 'topic_membership.remove':
153
+ row = tx.one('SELECT status FROM topic_memberships WHERE topic=? AND subject=?', (target, subject))
154
+ require(row is not None, 'recovery_fact_missing')
155
+ if row[0] != 'removed':
156
+ tx.execute("UPDATE topic_memberships SET status='removed' WHERE topic=? AND subject=?",
157
+ (target, subject), write=True)
158
+ return True
159
+ return False
160
+ if kind == 'topic_ban.lift':
161
+ row = tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', (target, subject))
162
+ require(row is not None, 'recovery_fact_missing')
163
+ if row[0] != 'lifted':
164
+ tx.execute("UPDATE topic_bans SET status='lifted' WHERE topic=? AND subject=?",
165
+ (target, subject), write=True)
166
+ return True
167
+ return False
168
+ if kind in {'identity_key.retire', 'encryption_key.retire'}:
169
+ table = 'identity_keys' if kind == 'identity_key.retire' else 'encryption_subkeys'
170
+ row = _owned(tx.one(f'SELECT subject,retired_at,is_primary FROM {table} WHERE key_id=?',
171
+ (target,)), subject)
172
+ earliest = _earlier(row[1], at)
173
+ changed = row[1] != earliest or row[2] != 0
174
+ if changed:
175
+ tx.execute(f'UPDATE {table} SET retired_at=?,is_primary=0 WHERE key_id=?',
176
+ (earliest, target), write=True)
177
+ if kind == 'identity_key.retire':
178
+ changed = _revoke_credential(tx, subject, target, at) or changed
179
+ vault = tx.one('SELECT signing_key_id,signing_nonce,signing_ciphertext,age_ciphertext '
180
+ 'FROM custodial_vault WHERE subject=?', (subject,))
181
+ if vault is not None and vault[0] == target and (vault[1] is not None or vault[2] is not None):
182
+ status = 'decrypt_only' if vault[3] is not None else 'destroyed'
183
+ tx.execute('UPDATE custodial_vault SET signing_nonce=NULL,signing_ciphertext=NULL,'
184
+ 'status=? WHERE subject=?', (status, subject), write=True)
185
+ changed = True
186
+ return changed
187
+ require(kind == 'vault.destroy', 'recovery_checkpoint_invalid')
188
+ row = tx.one('SELECT encryption_key_id,signing_nonce,signing_ciphertext,age_nonce,'
189
+ 'age_ciphertext,status FROM custodial_vault WHERE subject=?', (subject,))
190
+ require(row is not None, 'recovery_fact_missing')
191
+ require(row[0] == target, 'recovery_fact_subject_mismatch')
192
+ if any(value is not None for value in row[1:5]) or row[5] != 'destroyed':
193
+ tx.execute("UPDATE custodial_vault SET signing_nonce=NULL,signing_ciphertext=NULL,"
194
+ "age_nonce=NULL,age_ciphertext=NULL,status='destroyed',destroyed_at=? WHERE subject=?",
195
+ (at, subject), write=True)
196
+ return True
197
+ return False
198
+
199
+
200
+ async def replay(store, packet, *, pin, config_dir):
201
+ """Physical-console entry; neither a TTY flag nor an HTTP caller can authorize it."""
202
+ from msg.admin.root import require_local_console
203
+ operator = require_local_console(config_dir)
204
+ return await _replay(store, packet, pin=pin, operator=operator)
205
+
206
+
207
+ async def _replay(store, packet, *, pin, operator):
208
+ """Atomically apply an explicitly pinned supported deny-only log to quarantine.
209
+
210
+ Always re-apply facts, including after a repeated invocation/restart: cached
211
+ watermarks are not evidence that an authorization row has not been restored.
212
+ This neither imports keys, emits effects, nor clears quarantine. topic_ban.lift
213
+ is the only topic-ban fact; it does not reconcile ACLs or promote production.
214
+ """
215
+ require(isinstance(operator, str) and bool(operator), 'recovery_operator_required')
216
+ body = verify_checkpoint(packet, pin=pin)
217
+ async with store.transaction(write=True) as tx:
218
+ require(active(tx), 'recovery_quarantine_required')
219
+ gate = tx.setting(SETTING)
220
+ require(isinstance(gate, dict) and gate.get('format') == 'msg-recovery-quarantine-v1' and
221
+ gate.get('outbound_enabled') is False and gate.get('authority') == 'health_only',
222
+ 'recovery_quarantine_invalid')
223
+ require(gate.get('source_backup_sha256') == body['source_backup_sha256'],
224
+ 'recovery_checkpoint_backup_mismatch')
225
+ previous = tx.setting('recovery_replay')
226
+ if previous is not None:
227
+ require(isinstance(previous, dict) and previous.get('format') == FORMAT and
228
+ type(previous.get('sequence')) is int and 0 <= previous['sequence'] <= pin.sequence and
229
+ previous.get('prefix_digest') == digest(body['entries'][:previous['sequence']]),
230
+ 'recovery_checkpoint_regression')
231
+ changed = 0
232
+ for fact in body['entries']:
233
+ changed += bool(await _apply(tx, fact))
234
+ receipt = {'format': FORMAT, 'checkpoint_digest': pin.digest, 'sequence': pin.sequence,
235
+ 'prefix_digest': digest(body['entries']), 'scope': 'supported_deny_only_facts',
236
+ 'promotion': 'blocked', 'backup_retired': False}
237
+ if changed:
238
+ tx.set_setting('authorization_epoch', tx.setting('authorization_epoch', 0) + 1)
239
+ if changed or previous != receipt:
240
+ tx.set_setting('recovery_replay', receipt)
241
+ event = Event(id='audit_' + uuid4().hex, type='recovery.revocation_replay',
242
+ time=datetime.now(UTC), request_id=pin.digest, actor=ROOT_SUBJECT, subject=ROOT_SUBJECT,
243
+ resources=(), data=dict(receipt, operator=operator,
244
+ checkpoint_key_id=key_id(pin.public_key)))
245
+ await tx.append_audit(AuditEvent(event=event, authority=(), before_digest=digest(previous),
246
+ after_digest=digest(receipt), previous_digest=None, entry_digest='', result='quarantined'))
247
+ return dict(receipt, changed=changed)
248
+
249
+
250
+ def selftest():
251
+ """Ephemeral signer test only; does not inspect or change any installation."""
252
+ from msg.security.crypto import Ed25519Signer
253
+ signer = Ed25519Signer.generate()
254
+ body = {'format': FORMAT, 'service': 'https://isolated.invalid',
255
+ 'source_backup_sha256': '0' * 64, 'sequence': 0, 'entries': []}
256
+ packet = {'checkpoint': body, 'signature': wire(signer.sign(
257
+ canonical(body), purpose='recovery-checkpoint-v1'))}
258
+ pin = TrustedCheckpointPin(service=body['service'], public_key=signer.public_key,
259
+ digest=digest(body), sequence=0)
260
+ require(verify_checkpoint(packet, pin=pin) == body, 'recovery_selftest_failed')
261
+ rejected = []
262
+ for invalid in (None, replace(pin, public_key=Ed25519Signer.generate().public_key)):
263
+ try:
264
+ verify_checkpoint(packet, pin=invalid)
265
+ except Failure as exc:
266
+ rejected.append(exc.code)
267
+ else:
268
+ raise Failure('recovery_selftest_failed')
269
+ require(rejected == ['recovery_checkpoint_pin_required', 'recovery_checkpoint_invalid'],
270
+ 'recovery_selftest_failed')
271
+ return {'format': FORMAT, 'scope': 'isolated_signature_contract_only',
272
+ 'checks': 3, 'status': 'passed', 'database_used': False,
273
+ 'production_evidence': False, 'promotion': 'blocked'}
274
+
275
+
276
+ if __name__ == '__main__':
277
+ import argparse
278
+ parser = argparse.ArgumentParser(description=__doc__)
279
+ parser.add_argument('--selftest', action='store_true', required=True)
280
+ parser.parse_args()
281
+ print(canonical(selftest()).decode())
@@ -0,0 +1,37 @@
1
+ """Import an offline dump and its quarantine gate in the same PostgreSQL commit."""
2
+ from pathlib import Path
3
+ import os
4
+ import subprocess
5
+
6
+ from msg.core.codec import canonical
7
+ from msg.security.quarantine import SETTING
8
+
9
+
10
+ def restore_dump(dump, *, safe_dsn, env, quarantine):
11
+ """Stream through a protected SQL file; COPY data requires psql, not execute().
12
+
13
+ pg_restore does not connect to PostgreSQL here. psql's single transaction
14
+ contains both the dump and the mandatory gate. An interruption before commit
15
+ leaves neither an imported snapshot nor a runnable ungated database.
16
+ """
17
+ script = Path(dump).parent / 'quarantined-restore.sql'
18
+ script.touch(mode=0o600, exist_ok=False)
19
+ try:
20
+ subprocess.run(['pg_restore', '--exit-on-error', '--no-owner', '--no-acl',
21
+ '--file', str(script), str(dump)],
22
+ env=env, check=True, capture_output=True)
23
+ # Values are fixed recovery metadata, not secrets. Escape as a SQL
24
+ # literal (including backslashes) independently of session string flags.
25
+ value = canonical(quarantine).decode().replace('\\', '\\\\').replace("'", "''")
26
+ gate = ("\nSET search_path = public, pg_catalog;\n"
27
+ f"INSERT INTO settings(key,value) VALUES('{SETTING}',E'{value}') "
28
+ "ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value;\n")
29
+ with script.open('ab') as stream:
30
+ stream.write(gate.encode())
31
+ stream.flush()
32
+ os.fsync(stream.fileno())
33
+ subprocess.run(['psql', '--no-psqlrc', '--quiet', '--single-transaction',
34
+ '--set', 'ON_ERROR_STOP=1', '--dbname', safe_dsn, '--file', str(script)],
35
+ env=env, check=True, capture_output=True)
36
+ finally:
37
+ script.unlink(missing_ok=True)