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/transports/mcp.py ADDED
@@ -0,0 +1,127 @@
1
+ """MCP 2025-11-25 JSON-RPC over stateless Streamable HTTP or local stdio.
2
+
3
+ Remote calls carry the same signed operation envelope as other transports.
4
+ Local stdio exposes argument schemas directly and delegates signing to msg.
5
+ No root-administration operation is generated as an MCP tool.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import replace
9
+ from msg import __version__
10
+ from msg.core.codec import canonical,digest,loads
11
+ from msg.core.errors import Failure,require
12
+ from msg.core.executor import result_wire
13
+ from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA
14
+
15
+ PROTOCOL_VERSION='2025-11-25'
16
+ SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'})
17
+
18
+
19
+ class MCPServer:
20
+ def __init__(self,service, *, local_client=None):
21
+ self.service=service
22
+ self.local_client=local_client
23
+ self._requests={}
24
+
25
+ async def tools(self,cursor=None):
26
+ registry=self.service.registry
27
+ specs=registry.operations('network')
28
+ offset=0
29
+ if cursor:
30
+ offset=self.service.cursors.decode(cursor,'mcp-tools',registry.catalog()['digest'])
31
+ require(type(offset) is int and 0<=offset<=len(specs),'invalid_cursor')
32
+ if self.local_client is not None:
33
+ await registry.load_schemas(specs[offset:offset+8])
34
+ tools=[]
35
+ # Bounded discovery; an agent can request only the next useful page.
36
+ for spec in specs[offset:offset+8]:
37
+ tool_name=spec.name if spec.version==1 else f'{spec.name}@{spec.version}'
38
+ if self.local_client is not None:
39
+ input_schema=registry.schema(spec.input_schema)
40
+ else:
41
+ packet_schema={**REQUEST_SCHEMA,'properties':{**REQUEST_SCHEMA['properties'],
42
+ 'operation':{'const':spec.name},'contract_version':{'const':spec.version},
43
+ 'arguments':registry.schema(spec.input_schema)}}
44
+ input_schema={'type':'object','properties':{'packet':packet_schema},'required':['packet'],'additionalProperties':False}
45
+ tools.append({'name':tool_name,'description':f'{spec.name}@{spec.version}; {spec.effect}; contract /-/d/{spec.name.split(".",1)[0]}/{tool_name}',
46
+ 'inputSchema':input_schema,'outputSchema':RESULT_SCHEMA,
47
+ 'annotations':{'readOnlyHint':spec.effect=='read','idempotentHint':True,
48
+ 'destructiveHint':spec.effect!='read','openWorldHint':spec.effect=='external'}})
49
+ result={'tools':tools}
50
+ if offset+len(tools)<len(specs):
51
+ result['nextCursor']=self.service.cursors.encode('mcp-tools',registry.catalog()['digest'],offset+len(tools))
52
+ return result
53
+
54
+ async def handle(self,message):
55
+ id=message.get('id') if isinstance(message,dict) else None
56
+ try:
57
+ require(isinstance(message,dict) and message.get('jsonrpc')=='2.0' and
58
+ isinstance(message.get('method'),str),'invalid_jsonrpc')
59
+ require(id is None or type(id) in (str,int),'invalid_jsonrpc_id')
60
+ require(not isinstance(id,str) or len(id)<=128,'invalid_jsonrpc_id')
61
+ method=message['method']
62
+ params=message.get('params',{})
63
+ require(isinstance(params,dict),'invalid_jsonrpc_params')
64
+ if id is None:
65
+ # Notifications never execute operations, even if their method is
66
+ # tools/call. This prevents unacknowledged mutation messages.
67
+ return None
68
+ if method=='initialize':
69
+ requested=params.get('protocolVersion')
70
+ selected=requested if requested in SUPPORTED_VERSIONS else PROTOCOL_VERSION
71
+ result={'protocolVersion':selected,'capabilities':{'tools':{'listChanged':False}},
72
+ 'serverInfo':{'name':'msg.lmm.best','version':__version__},
73
+ 'instructions':'Read /AGENTS.md. Network operations use one signed envelope. Root administration is not available.'}
74
+ elif method=='ping':
75
+ result={}
76
+ elif method=='tools/list':
77
+ require(set(params)<={'cursor','_meta'},'invalid_jsonrpc_params')
78
+ result=await self.tools(params.get('cursor'))
79
+ elif method=='tools/call':
80
+ require(set(params)<={'name','arguments','_meta'} and isinstance(params.get('name'),str),'invalid_jsonrpc_params')
81
+ name=params['name']
82
+ operation,separator,version=name.rpartition('@')
83
+ if separator:
84
+ require(version.isdecimal() and int(version)>0,'invalid_operation_version')
85
+ spec=self.service.registry.operation(operation,int(version))
86
+ else:
87
+ spec=self.service.registry.operation(name)
88
+ require('network' in spec.entries,'entry_not_allowed')
89
+ arguments=params.get('arguments',{})
90
+ if self.local_client is None:
91
+ require(isinstance(arguments,dict) and set(arguments)=={'packet'},'signed_packet_required')
92
+ packet=decode_packet(arguments['packet'],self.service.settings.server.limits.max_request_bytes)
93
+ require(packet.operation==spec.name and packet.contract_version==spec.version,'operation_mismatch')
94
+ output=await self.service.executor.execute(packet,entry='network')
95
+ else:
96
+ require(isinstance(arguments,dict),'invalid_jsonrpc_params')
97
+ metadata=params.get('_meta',{})
98
+ expected=metadata.get('msg/expected_generations',())
99
+ request_id=metadata.get('msg/request_id')
100
+ cache_key=(type(id).__name__,id)
101
+ request_digest=digest((params['name'],arguments,expected))
102
+ existing=self._requests.get(cache_key)
103
+ if existing:
104
+ require(existing[0]==request_digest,'jsonrpc_id_conflict')
105
+ request_id=existing[1]
106
+ packet=self.local_client.prepare(spec.name,arguments,expected=expected,
107
+ request_id=request_id,contract_version=spec.version)
108
+ self._requests[cache_key]=(request_digest,packet.request_id)
109
+ # This is a process-local replay cache, not per-account server state.
110
+ if len(self._requests)>256:
111
+ self._requests.pop(next(iter(self._requests)))
112
+ output=await self.local_client.send(packet)
113
+ value=result_wire(output)
114
+ encoded=canonical(value).decode()
115
+ # Structured content is authoritative. Avoid doubling large
116
+ # transfer chunks in the text compatibility projection.
117
+ text=encoded if len(encoded)<=8192 else canonical({'request_id':output.request_id,
118
+ 'status':output.status,'structured_content':True}).decode()
119
+ result={'content':[{'type':'text','text':text}],
120
+ 'structuredContent':value,'isError':output.status=='error'}
121
+ else:
122
+ return {'jsonrpc':'2.0','id':id,'error':{'code':-32601,'message':'method_not_found'}}
123
+ return {'jsonrpc':'2.0','id':id,'result':result}
124
+ except Failure as exc:
125
+ if id is None:
126
+ return None
127
+ return {'jsonrpc':'2.0','id':id,'error':{'code':-32602,'message':exc.code}}
@@ -0,0 +1,42 @@
1
+ """URL and compression boundaries; common wire contracts live in core.packet."""
2
+ from __future__ import annotations
3
+ import zlib
4
+ from msg.core.codec import unb64
5
+ from msg.core.errors import require, Failure
6
+ from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
7
+ from msg.core.models import OperationRequest, TokenProof
8
+ from msg.core.packet import (
9
+ REQUEST_SCHEMA, RESULT_SCHEMA, decode_packet, decode_result, safe_error_code,
10
+ )
11
+ from msg.transports.url_safety import contains_secret_fields
12
+
13
+
14
+ def require_url_safe_packet(packet: OperationRequest) -> None:
15
+ """Reject credentials and recovery claims before a GET URL is dispatched.
16
+
17
+ A signature is a bounded request proof; TokenProof and issuance claims are
18
+ reusable secrets. This guards both clients constructing URL packets and the
19
+ server receiving hand-built packets, including gzip envelopes.
20
+ """
21
+ require(not isinstance(packet.proof, TokenProof), 'secure_channel_required')
22
+ require(packet.operation not in SECRET_DELIVERY_MIN_VERSION, 'secure_channel_required')
23
+
24
+ require(not contains_secret_fields(packet.arguments), 'secure_channel_required')
25
+
26
+
27
+ def gunzip(raw,limit):
28
+ inflater=zlib.decompressobj(wbits=31)
29
+ try:
30
+ result=inflater.decompress(raw,limit+1)
31
+ require(len(result)<=limit and not inflater.unconsumed_tail,'request_too_large')
32
+ result+=inflater.flush(limit+1-len(result))
33
+ require(len(result)<=limit and inflater.eof and not inflater.unused_data,'invalid_gzip')
34
+ return result
35
+ except zlib.error as exc:
36
+ raise Failure('invalid_gzip') from exc
37
+
38
+
39
+ def path_packet(encoded,encoding,limit):
40
+ raw=unb64(encoded,limit=limit)
41
+ require(encoding in {'j','gz'},'unknown_encoding')
42
+ return decode_packet(gunzip(raw,limit) if encoding=='gz' else raw,limit)
@@ -0,0 +1,65 @@
1
+ """Readable v3 paths: x/{collection} enters a node; up/1 closes it."""
2
+ import re
3
+ from urllib.parse import quote,unquote_to_bytes
4
+ from msg.core.errors import Failure,require
5
+ from msg.core.read_query import MAX_READ_DEPTH,NESTED_FIELDS,ROOT_FIELDS
6
+ from msg.transports.dictionary import READ_QUERY_V1_SORT,READ_QUERY_V1_FIELDS
7
+
8
+
9
+ def decode_read_tree_path(raw_path):
10
+ parts=raw_path.split(b'/')
11
+ require(len(parts)>=6 and parts[:4] in
12
+ ([b'',b'_r',b'q',b'3'],[b'',b'_read',b'q',b'3']),'invalid_path')
13
+ pieces=parts[4:]
14
+ proof=None
15
+ if len(pieces)>=2 and pieces[-2]==b'p':
16
+ proof=pieces[-1];pieces=pieces[:-2]
17
+ require(pieces and len(pieces)%2==0,'invalid_path')
18
+ root={'query_version':3}
19
+ stack=[root]
20
+ fields={value:key for key,value in READ_QUERY_V1_FIELDS.items()}
21
+ sorts={value:key for key,value in READ_QUERY_V1_SORT.items()}
22
+ for code,encoded in zip(pieces[::2],pieces[1::2],strict=True):
23
+ require(encoded and re.search(rb'%(?![0-9A-Fa-f]{2})',encoded) is None,'invalid_path')
24
+ try:value=unquote_to_bytes(encoded).decode('utf-8')
25
+ except UnicodeDecodeError as exc:raise Failure('invalid_path') from exc
26
+ require(quote(value,safe=',').encode()==encoded,'invalid_path')
27
+ node=stack[-1]
28
+ if code==b'x':
29
+ require(value in {'children','replies'},'invalid_nested_query')
30
+ require(len(stack)<=MAX_READ_DEPTH,'query_cost_exceeded')
31
+ tree=node.setdefault('expand',{})
32
+ require(value not in tree,'duplicate_query_parameter')
33
+ tree[value]={};stack.append(tree[value])
34
+ continue
35
+ if code==b'up':
36
+ require(value=='1' and len(stack)>1,'invalid_path')
37
+ stack.pop();continue
38
+ names={b'r':'parent',b't':'type',b's':'sort',b'f':'fields',b'n':'limit',b'a':'cursor',
39
+ b'co':'collection',b'pa':'parent'}
40
+ require(code in names,'unknown_query_parameter')
41
+ field=names[code]
42
+ require(len(stack)==1 or field in {'limit','fields'},'invalid_nested_query')
43
+ require(field not in node,'duplicate_query_parameter')
44
+ if field=='limit':
45
+ require(value.isdecimal() and 1<=int(value)<=(100 if len(stack)==1 else 10),
46
+ 'query_cost_exceeded')
47
+ value=int(value)
48
+ elif field=='fields':
49
+ selected=value.split(',')
50
+ allowed=ROOT_FIELDS if len(stack)==1 else NESTED_FIELDS
51
+ require(selected and len(selected)==len(set(selected)) and
52
+ all(code in fields and fields[code] in allowed for code in selected),
53
+ 'unknown_projection_field')
54
+ value=[fields[code] for code in selected]
55
+ elif field=='sort':
56
+ require(value in sorts,'invalid_sort');value=sorts[value]
57
+ elif field=='collection':
58
+ require(value in {'children','replies'},'invalid_nested_query')
59
+ node[field]=value
60
+ require(len(stack)==1,'invalid_path')
61
+ if 'cursor' in root:
62
+ require(set(root)=={'query_version','cursor'},'cursor_query_mismatch')
63
+ return {'cursor':root['cursor']},proof
64
+ require(root.get('parent'),'read_query_root_required')
65
+ return root,proof
@@ -0,0 +1,36 @@
1
+ """Local MCP is a signed network client, never a msgd administrative proxy."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from types import SimpleNamespace
7
+
8
+ from msg.core.codec import canonical,loads
9
+ from msg.core.cursors import CursorCodec
10
+ from msg.core.errors import Failure
11
+ from msg.transports.mcp import MCPServer
12
+
13
+
14
+ async def serve_stdio(client,input_stream=None,output_stream=None):
15
+ input_stream=input_stream or sys.stdin.buffer
16
+ output_stream=output_stream or sys.stdout.buffer
17
+ registry=await client.contract_registry()
18
+ service=SimpleNamespace(registry=registry,cursors=CursorCodec(os.urandom(32)))
19
+ server=MCPServer(service,local_client=client)
20
+ limit=(await client.transport.discover()).max_request_bytes
21
+ while True:
22
+ line=await asyncio.to_thread(input_stream.readline,limit+2)
23
+ if not line:
24
+ break
25
+ try:
26
+ if len(line)>limit or not line.endswith(b'\n'):
27
+ # Framing cannot be recovered safely from an unterminated oversized line.
28
+ error={'jsonrpc':'2.0','id':None,'error':{'code':-32700,'message':'message_too_large'}}
29
+ output_stream.write(canonical(error)+b'\n');output_stream.flush()
30
+ break
31
+ result=await server.handle(loads(line))
32
+ except Failure as exc:
33
+ result={'jsonrpc':'2.0','id':None,'error':{'code':-32700,'message':exc.code}}
34
+ if result is not None:
35
+ output_stream.write(canonical(result)+b'\n')
36
+ output_stream.flush()
@@ -0,0 +1,123 @@
1
+ """Bounded URL inspection shared by HTTP, PathGET and clients.
2
+
3
+ Decoding here is for rejection only. It never produces a rewritten route or
4
+ business arguments. Ordinary data (including percent literals and quoted /)
5
+ still reaches its original parser. Only explicit credential field names and
6
+ legacy credential slots are classified; arbitrary text is not a secret scanner.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Mapping
12
+ from urllib.parse import SplitResult, unquote_to_bytes, urlsplit
13
+
14
+ from msg.core.errors import Failure, require
15
+
16
+ MAX_DECODE_LAYERS = 8
17
+ _SECRET_NAMES = frozenset({
18
+ 'token', 'accesstoken', 'refreshtoken', 'recoverysecret', 'newrecoverysecret',
19
+ 'privatekey', 'secret', 'clientsecret', 'bootstrapclaim', 'password', 'apikey',
20
+ 'authorization',
21
+ })
22
+ _BAD_ESCAPE = re.compile(rb'%(?![0-9a-fA-F]{2})')
23
+ _CONTROL = re.compile(rb'[\x00-\x20\x7f]')
24
+
25
+
26
+ def _layers(raw: bytes):
27
+ """At most eight decoding passes; never expose the rejected input."""
28
+ current = raw
29
+ for _ in range(MAX_DECODE_LAYERS + 1):
30
+ yield current
31
+ decoded = unquote_to_bytes(current)
32
+ if decoded == current:
33
+ return
34
+ current = decoded
35
+ require(False, 'invalid_path')
36
+
37
+
38
+ def is_secret_field(name: str | bytes) -> bool:
39
+ raw = name.encode('utf-8') if isinstance(name, str) else name
40
+ for layer in _layers(raw):
41
+ # Understand conventional nested field labels, not arbitrary substrings.
42
+ for part in re.split(rb'[.\[\]]', layer.lower()):
43
+ normalized = part.strip().replace(b'_', b'').replace(b'-', b'')
44
+ if normalized.decode('ascii', errors='replace') in _SECRET_NAMES:
45
+ return True
46
+ return False
47
+
48
+
49
+ def contains_secret_fields(value) -> bool:
50
+ pending = [value]
51
+ visited = 0
52
+ while pending:
53
+ item = pending.pop()
54
+ visited += 1
55
+ require(visited <= 16384, 'request_too_large')
56
+ if isinstance(item, Mapping):
57
+ if any(is_secret_field(key) for key in item):
58
+ return True
59
+ pending.extend(item.values())
60
+ elif isinstance(item, (list, tuple)):
61
+ pending.extend(item)
62
+ return False
63
+
64
+
65
+ def require_safe_request_target(raw_path: bytes, query: bytes = b'', *, maximum: int) -> None:
66
+ """Inspect raw ASGI/request-target bytes before routing or sending a request."""
67
+ require(len(raw_path) + len(query) + bool(query) <= maximum, 'path_too_large')
68
+ require(b'#' not in raw_path and b'#' not in query, 'secure_channel_required')
69
+ require(raw_path.startswith(b'/') and not raw_path.startswith(b'//'), 'invalid_path')
70
+ require(not _CONTROL.search(raw_path) and not _CONTROL.search(query), 'invalid_path')
71
+ require(not _BAD_ESCAPE.search(raw_path) and not _BAD_ESCAPE.search(query), 'invalid_path')
72
+
73
+ # Inspect delimiters at every level, not just the name after one decoding.
74
+ # This catches both %2574oken and ordinary=x%26token=... without interpreting
75
+ # either spelling as an additional business parameter.
76
+ for layer in _layers(query):
77
+ for pair in re.split(rb'[&;]', layer):
78
+ require(not is_secret_field(pair.partition(b'=')[0]), 'secure_channel_required')
79
+
80
+ ambiguous = False
81
+ for layer in _layers(raw_path):
82
+ require(b'\\' not in layer and b'\x00' not in layer, 'invalid_path')
83
+ segments = layer.split(b'/')
84
+ if len(segments) >= 5 and segments[:3] == [b'', b'-', b'g']:
85
+ require(segments[4].lower() not in {b'token', b'bootstrap'},
86
+ 'secure_channel_required')
87
+ # A labelled assignment in a path is not a permitted secret channel.
88
+ for segment in segments:
89
+ if b'=' in segment:
90
+ require(not is_secret_field(segment.partition(b'=')[0]),
91
+ 'secure_channel_required')
92
+ if layer == b'/-' or layer.startswith(b'/-/'):
93
+ original = raw_path.split(b'/')
94
+ ambiguous |= (not (raw_path == b'/-' or raw_path.startswith(b'/-/')) or
95
+ any(part in {b'.', b'..'} for part in segments) or
96
+ any(b'%' in part for part in original[2:4]) or
97
+ (len(original) > 4 and original[2] in {b'g', b'p'} and
98
+ b'.' in original[3] and b'%' in original[4]))
99
+ require(not ambiguous, 'not_found')
100
+
101
+
102
+ def require_safe_relative_url(path: str, *, maximum: int) -> None:
103
+ require(path.startswith('/') and not path.startswith('//'), 'invalid_relative_endpoint')
104
+ # Fragments are not transmitted; reject them before httpx/urlsplit can drop
105
+ # them. The server can only validate fragment-like bytes actually received.
106
+ raw_path, _, query = path.encode('utf-8').partition(b'?')
107
+ require_safe_request_target(raw_path, query, maximum=maximum)
108
+
109
+
110
+ def require_matching_host(values: list[str], expected: SplitResult) -> None:
111
+ """Check Host syntax before a URL parser can discard delimiters or controls."""
112
+ require(len(values) == 1 and re.fullmatch(
113
+ r'(?:[A-Za-z0-9._-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?', values[0])
114
+ is not None, 'forbidden_host')
115
+ try:
116
+ supplied = urlsplit('//' + values[0])
117
+ port = supplied.port
118
+ default = 443 if expected.scheme == 'https' else 80
119
+ require((port is None or port > 0) and supplied.hostname == expected.hostname and
120
+ (port if port is not None else default) == (expected.port or default),
121
+ 'forbidden_host')
122
+ except ValueError:
123
+ raise Failure('forbidden_host') from None