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/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Atomic communication for sandboxed agents."""
2
+ __version__ = "0.1.0a1"
msg/admin/__init__.py ADDED
File without changes
@@ -0,0 +1,56 @@
1
+ """Local import of a root-signed custodial backup retirement record.
2
+
3
+ Never imported by a network adapter, plugin or worker. RootAdmin calls these use
4
+ cases only after require_local_console(); tests call them against disposable
5
+ databases. The record is verified again on every read, so this import is not a
6
+ trusted cache of a success flag.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from msg.constants import ROOT_SUBJECT
11
+ from msg.core.codec import digest, loads
12
+ from msg.core.errors import require
13
+ from msg.core.models import AuditEvent, Event, ResourceRef
14
+ from msg.plugins.common import new_id
15
+ from msg.security.backup_retirement import FIELDS, DOMAIN, check, root_verifier, setting_key
16
+
17
+
18
+ def unsigned_statement(value):
19
+ require(isinstance(value, dict) and set(value) == FIELDS and value.get('domain') == DOMAIN,
20
+ 'backup_retirement_invalid')
21
+ return value
22
+
23
+
24
+ def completed_migration(tx, subject_id, old_identity_key_id):
25
+ """The one completed migration of this subject that retired exactly this old key."""
26
+ matches = [loads(body) for (body,) in tx.rows(
27
+ "SELECT body FROM custodial_upgrades WHERE subject=? AND status='completed' ORDER BY id",
28
+ (subject_id,)) if loads(body).get('old_identity_key_id') == old_identity_key_id]
29
+ require(len(matches) == 1, 'backup_retirement_migration_not_found')
30
+ return matches[0]
31
+
32
+
33
+ async def import_record(tx, record, *, now, operator):
34
+ body = record.get('statement') if isinstance(record, dict) else None
35
+ require(isinstance(body, dict) and isinstance(body.get('subject_id'), str) and
36
+ isinstance(body.get('old_identity_key_id'), str), 'backup_retirement_invalid')
37
+ subject = body['subject_id']
38
+ details = completed_migration(tx, subject, body['old_identity_key_id'])
39
+ attestation = check(record, await root_verifier(tx), subject, details, now=now)
40
+ key = setting_key(subject, attestation.old_identity_key_id)
41
+ previous = tx.setting(key)
42
+ tx.set_setting(key, record)
43
+ data = {'subject_id': subject, 'old_identity_key_id': attestation.old_identity_key_id,
44
+ 'old_encryption_key_id': attestation.old_encryption_key_id,
45
+ 'backup_sets': list(attestation.backup_sets), 'record_digest': attestation.record_digest,
46
+ 'signer_key_id': attestation.signer_key_id, 'operator': operator,
47
+ 'scope': 'listed_backup_sets_only'}
48
+ event = Event(id=new_id('audit'), type='root.custodial.backup_retirement', time=now,
49
+ request_id=new_id('local'), actor=ROOT_SUBJECT, subject=subject,
50
+ resources=(ResourceRef(id=subject),), data=data)
51
+ await tx.append_audit(AuditEvent(event=event,
52
+ authority=(ResourceRef(id=tx.setting('active_root_certificate')),),
53
+ before_digest=digest(previous) if previous is not None else None,
54
+ after_digest=attestation.record_digest, previous_digest=None, entry_digest='',
55
+ result='imported'))
56
+ return attestation
msg/admin/backups.py ADDED
@@ -0,0 +1,341 @@
1
+ """Explicit local data backup; encrypted root material is a separate operation."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import hashlib
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import tempfile
9
+ import tomllib
10
+ import zipfile
11
+ import psycopg
12
+ from psycopg.conninfo import conninfo_to_dict,make_conninfo
13
+ import subprocess
14
+ from msg.core.codec import canonical,loads
15
+ from msg.core.errors import Failure,require
16
+ from msg.config import load_settings,write_example
17
+ from msg.admin.restore_database import restore_dump
18
+ from msg.storage.git import durable_write
19
+
20
+ _FORMAT = 'msg-data-backup-v4'
21
+ _KEYS = ('online.key', 'receipt.key', 'tokens.key')
22
+
23
+
24
+ def _regular_tree(path):
25
+ """Refuse links before copying secrets or bytes into an archive."""
26
+ require(path.is_dir() and not path.is_symlink(), 'backup_source_missing')
27
+ for root, directories, files in os.walk(path, followlinks=False):
28
+ for name in (*directories, *files):
29
+ entry=Path(root)/name
30
+ require(not entry.is_symlink(), 'backup_symlink_forbidden')
31
+ require(entry.is_dir() or entry.is_file(), 'backup_special_file_forbidden')
32
+
33
+
34
+ def _db_refs(connection):
35
+ """Revision and transfer chunk SQL pointers into Git/CAS."""
36
+ contents={}
37
+ revisions=[]
38
+ for revision_id, raw in connection.execute('SELECT id,body FROM revisions ORDER BY id'):
39
+ body=loads(raw)
40
+ content=body['content']
41
+ require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
42
+ contents[content['digest'][7:]]=content['size']
43
+ revisions.append(revision_id)
44
+ for (raw,) in connection.execute('SELECT body FROM chunks ORDER BY transfer_id,"offset"'):
45
+ content=loads(raw)['content']
46
+ require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
47
+ contents[content['digest'][7:]]=content['size']
48
+ from msg.market.references import content_references
49
+ for content in content_references(connection.execute):
50
+ require(content['digest'].startswith('sha256:'), 'backup_reference_invalid')
51
+ contents[content['digest'][7:]]=content['size']
52
+ live={row[0] for row in connection.execute('SELECT revision FROM resources WHERE revision IS NOT NULL')}
53
+ require(live.issubset(set(revisions)), 'backup_revision_missing')
54
+ return {'contents':contents, 'revisions':revisions}
55
+
56
+
57
+ def _verify_storage(directory, refs, *, settings=None):
58
+ """Verify archived bytes, Git objects and the SQL-referenced content index."""
59
+ content=(settings.server.content_dir if settings is not None else directory/'content')
60
+ blobs=(settings.server.blob_dir if settings is not None else directory/'blobs')
61
+ repos=(settings.server.repositories_dir if settings is not None else directory/'repositories')
62
+ require(content.is_dir() and (content/'private.git').is_dir() and
63
+ (content/'index').is_dir() and blobs.is_dir(),
64
+ 'backup_storage_missing')
65
+ index_files={path.name:path for path in (content/'index').iterdir() if path.is_file()}
66
+ require(set(refs['contents']).issubset(index_files), 'backup_content_missing')
67
+ for key,index in index_files.items():
68
+ entry=loads(index.read_bytes())
69
+ size=refs['contents'].get(key,entry.get('size'))
70
+ require(len(key)==64 and all(c in '0123456789abcdef' for c in key) and
71
+ type(size) is int and size>=0, 'backup_reference_invalid')
72
+ require(entry.get('size')==size, 'backup_content_size_mismatch')
73
+ if entry.get('kind')=='binary':
74
+ target=blobs/key
75
+ require(target.is_file() and target.stat().st_size==size and
76
+ _hash(target)==key, 'backup_content_digest_mismatch')
77
+ else:
78
+ require(entry.get('kind')=='git', 'backup_content_kind_invalid')
79
+ oid=entry.get('oid')
80
+ require(type(oid) is str and len(oid) in (40,64) and
81
+ all(c in '0123456789abcdef' for c in oid), 'backup_git_oid_invalid')
82
+ status,actual_size,actual_hash=_git_blob_digest(content/'private.git',oid)
83
+ require(status==0 and actual_size==size and actual_hash==key,
84
+ 'backup_git_content_mismatch')
85
+ for revision in refs['revisions']:
86
+ require(type(revision) is str and '/' not in revision and revision not in ('.','..'),
87
+ 'backup_revision_invalid')
88
+ pointer=content/'revisions'/revision
89
+ require(pointer.is_file(), 'backup_revision_content_missing')
90
+ oid=pointer.read_text().strip()
91
+ require(len(oid) in (40,64) and all(c in '0123456789abcdef' for c in oid),
92
+ 'backup_revision_oid_invalid')
93
+ process=subprocess.run(['git','--git-dir',str(content/'private.git'),
94
+ 'cat-file','-e',oid+'^{commit}'],capture_output=True,check=False)
95
+ require(process.returncode==0, 'backup_revision_content_missing')
96
+ for repository in (repos.iterdir() if repos.exists() else ()):
97
+ require(repository.is_dir() and repository.name.endswith('.git'),
98
+ 'backup_repository_invalid')
99
+ # ZIP stores files only. An empty bare repository still needs these
100
+ # structural directories; recreating them changes no Git object/ref.
101
+ for name in ('objects/info','objects/pack','refs/heads','refs/tags'):
102
+ (repository/name).mkdir(parents=True,exist_ok=True)
103
+ process=subprocess.run(['git','--git-dir',str(repository),'fsck','--full','--no-reflogs'],
104
+ capture_output=True,check=False)
105
+ require(process.returncode==0, 'backup_git_repository_invalid')
106
+ objects=repository/'lfs'/'objects'
107
+ if not objects.exists():
108
+ continue
109
+ for path in objects.rglob('*'):
110
+ if not path.is_file():
111
+ continue
112
+ oid=path.name
113
+ require(len(oid)==64 and all(c in '0123456789abcdef' for c in oid) and
114
+ path.relative_to(objects).parts==(oid[:2],oid[2:4],oid),
115
+ 'backup_lfs_path_invalid')
116
+ canonical_blob=blobs/oid
117
+ require(canonical_blob.is_file() and _hash(path)==oid and
118
+ path.stat().st_size==canonical_blob.stat().st_size and
119
+ _hash(canonical_blob)==oid, 'backup_lfs_digest_mismatch')
120
+
121
+
122
+ def _relink_lfs(data_dir, settings):
123
+ """ZIP cannot retain hardlinks; restore canonical shared-CAS link counts."""
124
+ repositories=settings.server.repositories_dir
125
+ if not repositories.exists():
126
+ return
127
+ for repository in repositories.iterdir():
128
+ objects=repository/'lfs'/'objects'
129
+ if not objects.exists():
130
+ continue
131
+ for path in objects.rglob('*'):
132
+ if not path.is_file():
133
+ continue
134
+ shared=settings.server.blob_dir/path.name
135
+ require(path.stat().st_dev==shared.stat().st_dev,
136
+ 'restore_lfs_shared_volume_required')
137
+ temporary=path.with_name('.relink-'+path.name)
138
+ try:
139
+ os.link(shared,temporary)
140
+ os.replace(temporary,path)
141
+ finally:
142
+ temporary.unlink(missing_ok=True)
143
+
144
+
145
+ def _write_restored_config(source, settings, postgres_dsn):
146
+ """Retain policy, but bind only to the new isolated storage and loopback."""
147
+ raw=tomllib.loads(source.read_text())
148
+ server={'service_url':settings.service_url,'listen':'127.0.0.1','port':8042}
149
+ for name in ('public_web_origin','temporary_ttl','transfer_ttl'):
150
+ if name in raw.get('server',{}):
151
+ server[name]=raw['server'][name]
152
+ storage={'postgres_dsn':postgres_dsn,
153
+ 'content':str(settings.server.content_dir),
154
+ 'repositories':str(settings.server.repositories_dir),
155
+ 'blobs':str(settings.server.blob_dir),
156
+ 'staging':str(settings.server.staging_dir),
157
+ 'service_keys':str(settings.service_keys)}
158
+ sections={'server':server,'storage':storage}
159
+ for name in ('identity','money','limits','plugins','tools'):
160
+ if name in raw:
161
+ sections[name]=raw[name]
162
+ lines=[]
163
+ for section,values in sections.items():
164
+ lines.append(f'[{section}]')
165
+ for name,value in values.items():
166
+ lines.append(f'{name} = {canonical(value).decode()}')
167
+ lines.append('')
168
+ recovery=raw.get('recovery',{})
169
+ if recovery:
170
+ lines.append('[recovery]')
171
+ for name,value in recovery.items():
172
+ if name!='custodians':
173
+ lines.append(f'{name} = {canonical(value).decode()}')
174
+ lines.append('')
175
+ for custodian in recovery.get('custodians',()):
176
+ lines.append('[[recovery.custodians]]')
177
+ for name,value in custodian.items():
178
+ lines.append(f'{name} = {canonical(value).decode()}')
179
+ lines.append('')
180
+ path=settings.config_dir/'msgd.toml'
181
+ path.write_text('\n'.join(lines))
182
+ os.chmod(path,0o600)
183
+
184
+
185
+ def _hash(path):
186
+ value=hashlib.sha256()
187
+ with path.open('rb') as source:
188
+ while chunk:=source.read(65536):value.update(chunk)
189
+ return value.hexdigest()
190
+
191
+
192
+ def _git_blob_digest(repository, oid):
193
+ value=hashlib.sha256()
194
+ size=0
195
+ with subprocess.Popen(['git','--git-dir',str(repository),'cat-file','blob',oid],
196
+ stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) as process:
197
+ while chunk:=process.stdout.read(1024*1024):
198
+ value.update(chunk);size+=len(chunk)
199
+ status=process.wait()
200
+ return status,size,value.hexdigest()
201
+
202
+
203
+ async def backup(app,destination):
204
+ destination=Path(destination)
205
+ require(not destination.exists() and not destination.is_symlink(),'backup_destination_exists')
206
+ destination.parent.mkdir(parents=True,exist_ok=True)
207
+ with tempfile.TemporaryDirectory(prefix='msg-backup-',dir=destination.parent) as tmp:
208
+ directory=Path(tmp)
209
+ async with app.metadata.transaction(write=True) as tx:
210
+ # PostgreSQL's transaction-scoped deployment advisory lock excludes
211
+ # other msgd writers while pg_dump uses its own MVCC connection.
212
+ # Storage objects are published before their SQL pointers commit.
213
+ await asyncio.to_thread(app.metadata.backup,directory/'metadata.dump')
214
+ with psycopg.connect(app.settings.server.postgres_dsn) as connection:
215
+ refs=_db_refs(connection)
216
+ for source,name in ((app.settings.server.content_dir,'content'),
217
+ (app.settings.server.repositories_dir,'repositories'),
218
+ (app.settings.server.blob_dir,'blobs'),
219
+ (app.settings.server.staging_dir,'staging')):
220
+ if source.exists():
221
+ _regular_tree(source)
222
+ await asyncio.to_thread(shutil.copytree,source,directory/name)
223
+ else:
224
+ (directory/name).mkdir()
225
+ _regular_tree(app.settings.service_keys)
226
+ await asyncio.to_thread(shutil.copytree,app.settings.service_keys,directory/'service')
227
+ require(all((directory/'service'/name).is_file() for name in _KEYS),
228
+ 'service_keys_missing')
229
+ require(app.settings.trust_file.is_file() and not app.settings.trust_file.is_symlink(),
230
+ 'backup_trust_missing')
231
+ shutil.copy2(app.settings.trust_file,directory/'root-public.json')
232
+ # Keep policy/configuration for review. Restore rewrites storage paths,
233
+ # disables Valkey and does not copy mail credentials.
234
+ config_source=(app.settings.config_dir/'msgd.toml'
235
+ if (app.settings.config_dir/'msgd.toml').exists()
236
+ else app.settings.config_dir/'server.toml')
237
+ require(config_source.is_file() and not config_source.is_symlink(),
238
+ 'backup_configuration_missing')
239
+ shutil.copy2(config_source,directory/'source-config.toml')
240
+ _verify_storage(directory,refs)
241
+ files={str(path.relative_to(directory)):_hash(path) for path in directory.rglob('*') if path.is_file()}
242
+ manifest={'format':_FORMAT,'service_url':app.settings.service_url,'files':files,
243
+ 'references':refs,'root_private_key_included':False,
244
+ 'mail_credentials_included':False}
245
+ (directory/'manifest.json').write_bytes(canonical(manifest))
246
+ partial=directory/'archive.zip'
247
+ with zipfile.ZipFile(partial,'x',compression=zipfile.ZIP_DEFLATED) as archive:
248
+ for name in ('manifest.json',*sorted(files)):
249
+ archive.write(directory/name,name)
250
+ os.chmod(partial,0o600)
251
+ with partial.open('rb') as stream:os.fsync(stream.fileno())
252
+ os.link(partial,destination)
253
+ return {'status':'backup_created','path':str(destination),'sha256':_hash(destination),
254
+ 'root_private_key_included':False}
255
+
256
+
257
+ def restore(source,config_dir,data_dir,*,postgres_dsn='service=msgd'):
258
+ """Restore only into new locations and an empty PostgreSQL database."""
259
+ config_dir,data_dir=Path(config_dir),Path(data_dir)
260
+ require(not config_dir.exists() and not data_dir.exists(),'restore_target_exists')
261
+ config_dir.parent.mkdir(parents=True,exist_ok=True)
262
+ data_dir.parent.mkdir(parents=True,exist_ok=True)
263
+ with tempfile.TemporaryDirectory(prefix='msg-restore-',dir=data_dir.parent) as temp:
264
+ directory=Path(temp)
265
+ with zipfile.ZipFile(source) as archive:
266
+ names=archive.namelist()
267
+ require(len(names)==len(set(names)) and 'manifest.json' in names,'invalid_backup')
268
+ manifest=loads(archive.read('manifest.json'))
269
+ require(manifest.get('format')==_FORMAT and manifest.get('root_private_key_included') is False,
270
+ 'invalid_backup')
271
+ require(set(names)==set(manifest['files'])|{'manifest.json'},'backup_file_mismatch')
272
+ for info in archive.infolist():
273
+ name=info.filename
274
+ require(not name.startswith('/') and '\\' not in name and
275
+ all(part not in {'','..','.'} for part in name.split('/')),'unsafe_backup_path')
276
+ require((info.external_attr>>16)&0o170000 not in {0o120000,0o060000,0o020000},'unsafe_backup_entry')
277
+ target=directory/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
278
+ with archive.open(info) as incoming,target.open('xb') as output:
279
+ shutil.copyfileobj(incoming,output,length=65536)
280
+ os.chmod(target,0o600)
281
+ if name!='manifest.json':require(_hash(target)==manifest['files'][name],'backup_digest_mismatch')
282
+ # Empty storage directories have no ZIP file entries.
283
+ for name in ('content','repositories','blobs','staging'):
284
+ (directory/name).mkdir(exist_ok=True)
285
+ require((directory/'metadata.dump').is_file() and
286
+ (directory/'root-public.json').is_file() and
287
+ (directory/'source-config.toml').is_file() and
288
+ all((directory/'service'/name).is_file() for name in _KEYS),
289
+ 'backup_required_file_missing')
290
+ _verify_storage(directory,manifest['references'])
291
+ fields=conninfo_to_dict(postgres_dsn)
292
+ password=fields.pop('password',None)
293
+ env=os.environ.copy()
294
+ if password is not None:env['PGPASSWORD']=password
295
+ safe_dsn=make_conninfo(**fields)
296
+ with psycopg.connect(postgres_dsn) as connection:
297
+ require(connection.execute(
298
+ "SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1"
299
+ ).fetchone() is None,'restore_database_not_empty')
300
+ quarantine={'format':'msg-recovery-quarantine-v1','outbound_enabled':False,
301
+ 'source_backup_sha256':_hash(Path(source)),
302
+ 'revocation_replay':'required','authority':'health_only'}
303
+ restore_dump(directory/'metadata.dump',safe_dsn=safe_dsn,env=env,
304
+ quarantine=quarantine)
305
+ with psycopg.connect(postgres_dsn) as connection:
306
+ require(_db_refs(connection)==manifest['references'],
307
+ 'backup_database_reference_mismatch')
308
+ row=connection.execute("SELECT value FROM settings WHERE key='runtime_config'").fetchone()
309
+ runtime=loads(row[0]) if row is not None else {}
310
+ require(type(runtime) is dict,'backup_runtime_config_invalid')
311
+ runtime['accept_writes']=False
312
+ runtime['cleanup_enabled']=False
313
+ connection.execute('''INSERT INTO settings(key,value) VALUES('runtime_config',%s)
314
+ ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value''',
315
+ (canonical(runtime).decode(),))
316
+ data_dir.mkdir(mode=0o700)
317
+ settings=write_example(config_dir,data_dir,manifest['service_url'],postgres_dsn=postgres_dsn)
318
+ # Install the fail-closed marker as soon as a runnable config exists,
319
+ # including if a later filesystem verification fails.
320
+ durable_write(config_dir/'recovery-drill.json',canonical({
321
+ **quarantine,'format':'msg-recovery-drill-v1'}),mode=0o600)
322
+ _write_restored_config(directory/'source-config.toml',settings,postgres_dsn)
323
+ settings=load_settings(config_dir)
324
+ for name,target in (('content',settings.server.content_dir),
325
+ ('repositories',settings.server.repositories_dir),
326
+ ('blobs',settings.server.blob_dir),
327
+ ('staging',settings.server.staging_dir)):
328
+ if (directory/name).exists():
329
+ target.parent.mkdir(parents=True,exist_ok=True)
330
+ shutil.move(str(directory/name),target)
331
+ settings.service_keys.parent.mkdir(parents=True,exist_ok=True)
332
+ shutil.move(str(directory/'service'),settings.service_keys)
333
+ os.chmod(settings.service_keys,0o700)
334
+ settings.trust_file.parent.mkdir(parents=True,exist_ok=True)
335
+ shutil.move(str(directory/'root-public.json'),settings.trust_file)
336
+ os.chmod(settings.trust_file,0o444)
337
+ _relink_lfs(data_dir,settings)
338
+ _verify_storage(data_dir,manifest['references'],settings=settings)
339
+ return {'status':'restored','root_admin_material':'restore_separate_encrypted_root_backup_locally',
340
+ 'mail':'disabled_until_configured','outbound':'disabled_recovery_drill',
341
+ 'revocation_replay':'required','promotion':'blocked'}
@@ -0,0 +1,77 @@
1
+ """Read-only lifecycle inspection and an isolated real-age history selftest."""
2
+ from dataclasses import replace
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+ from uuid import uuid4
6
+
7
+ import httpx
8
+
9
+ from msg.core.codec import digest, loads, wire
10
+ from msg.core.errors import require
11
+
12
+
13
+ async def inspect_custodial_history(tx):
14
+ from msg.security.custodial_migration import snapshot
15
+ constraint = tx.one("""SELECT pg_get_constraintdef(oid) FROM pg_constraint
16
+ WHERE conrelid='custodial_vault'::regclass AND conname='custodial_vault_lifecycle_check'""")
17
+ require(constraint is not None and 'decrypt_only' in constraint[0], 'custodial_lifecycle_schema_missing')
18
+ states = []
19
+ for subject, status, challenge, body in tx.rows(
20
+ 'SELECT subject,status,challenge,body FROM custodial_upgrades ORDER BY id'):
21
+ if status == 'failed':
22
+ continue
23
+ view = (await snapshot(tx, subject, status, loads(challenge), loads(body))).data
24
+ states.append({'subject': subject, 'challenge_id': view['challenge_id'], 'phase': view['phase'],
25
+ 'unresolved_count': len(view['unresolved_revisions']), 'inventory_changed': view['inventory_changed'],
26
+ 'retirement': view['retirement']})
27
+ return {'migrations': states, 'backup_retirement_verified': False,
28
+ 'verification_scope': 'online_state_only'}
29
+
30
+
31
+ async def check_custodial_history(app, now):
32
+ """Call only in the existing selftest disposable Test Root database."""
33
+ from msg.client import ClientState, MsgClient
34
+ from msg.client_custodial import migrate, transition
35
+ from msg.client_recovery import _age
36
+ from msg.core.models import Resource, Revision
37
+ from msg.transports.client import HTTPTransport
38
+ from msg.transports.http import create_app
39
+ require(app.selftest_run_id is not None, 'isolated_selftest_required')
40
+ with TemporaryDirectory(prefix='msg-history-selftest-') as directory:
41
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app))) as http:
42
+ client = MsgClient(ClientState(Path(directory), server=app.settings.service_url),
43
+ HTTPTransport(app.settings.service_url, http=http), clock=lambda: now)
44
+ created = client.checked(await client.custodial('history-' + uuid4().hex[:12]))
45
+ subject = client.state.subject
46
+ ciphertext = _age('--encrypt', '--recipient', created.data['encryption_recipient'],
47
+ input_data=b'isolated historical plaintext')
48
+ # Explicit fixture fact, not a new privileged network operation. The
49
+ # normal custodial credential ceiling does not grant keystore writes.
50
+ async with app.metadata.transaction(write=True) as tx:
51
+ folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject,))[0]
52
+ resource = Resource(id='ks_' + uuid4().hex, type='keystore', type_version=1,
53
+ name='history.age', parent=folder, owner=subject, group='g_public', mode=0o600,
54
+ generation=0, revision=None, state='active', created_at=now, created_by=subject,
55
+ modified_at=now, modified_by=subject)
56
+ await tx.insert(resource)
57
+ blob = await app.contents.put_bytes(ciphertext, 'application/octet-stream')
58
+ revision = Revision(format_version=1, id='v_' + uuid4().hex, resource_id=resource.id,
59
+ parents=(), content=blob, relations=(), actor=subject, subject=subject, author=subject,
60
+ created_at=now, manifest_digest='')
61
+ revision = replace(revision, manifest_digest=digest({k: v for k, v in wire(revision).items()
62
+ if k not in {'manifest_digest', 'signature'}}))
63
+ await app.contents.pin(blob, revision.id)
64
+ await app.contents.commit_revision(folder, revision)
65
+ await tx.append_revision(revision)
66
+ await tx.replace(replace(resource, generation=1, revision=revision.id), 0)
67
+ tx.set_setting('keystore_format:' + revision.id, 'age')
68
+ pending = client.checked(await client.upgrade_custodial('history-self-' + uuid4().hex[:12],
69
+ external_ciphertexts_migrated=True))
70
+ if pending.data['status'] != 'pending_rewrap':
71
+ return False
72
+ result = await migrate(client)
73
+ if not result['inventory']['history_recoverable']:
74
+ return False
75
+ done = client.checked(await transition(client, 'finalize', external_ciphertexts_migrated=True))
76
+ return (done.data['history_recoverable'] and done.data['retirement']['online_encryption_key_deleted']
77
+ and not done.data['retirement']['backup_retired'] and client.state.token is None)