sanka-cli 0.2.4__tar.gz → 0.2.6__tar.gz

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 (140) hide show
  1. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/PKG-INFO +13 -1
  2. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/README.md +11 -0
  3. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/pyproject.toml +2 -1
  4. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/report_codec.py +10 -0
  5. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/__init__.py +1 -1
  6. sanka_cli-0.2.6/src/sanka_cli/certificates.py +98 -0
  7. sanka_cli-0.2.6/src/sanka_cli/commands/cloud.py +550 -0
  8. sanka_cli-0.2.6/src/sanka_cli/commands/cloud_fleet.py +262 -0
  9. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_cli.py +1 -1
  10. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_report_codec.py +55 -1
  11. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_mcp.py +2 -2
  12. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_sanka_facade.py +1 -1
  13. sanka_cli-0.2.4/src/sanka_cli/commands/cloud.py +0 -240
  14. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/.gitignore +0 -0
  15. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/LICENSES/AGPL-3.0-only.txt +0 -0
  16. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/LICENSES/Apache-2.0.txt +0 -0
  17. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/NOTICE +0 -0
  18. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/__init__.py +0 -0
  19. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/_client.py +0 -0
  20. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/cli/__init__.py +0 -0
  21. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/cli/_compact.py +0 -0
  22. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/cli/_output.py +0 -0
  23. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/cli/_research.py +0 -0
  24. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/cli/py.typed +0 -0
  25. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/connector/__init__.py +0 -0
  26. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/connector/py.typed +0 -0
  27. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/__about__.py +0 -0
  28. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/__init__.py +0 -0
  29. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/concurrency.py +0 -0
  30. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/connector_client.py +0 -0
  31. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/connector_host.py +0 -0
  32. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/engine.py +0 -0
  33. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/__init__.py +0 -0
  34. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/continuous.py +0 -0
  35. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/errors.py +0 -0
  36. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/local.py +0 -0
  37. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/model.py +0 -0
  38. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/routes.py +0 -0
  39. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/scope.py +0 -0
  40. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/state.py +0 -0
  41. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/execution/validate.py +0 -0
  42. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/__init__.py +0 -0
  43. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/discovery.py +0 -0
  44. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/lifecycle.py +0 -0
  45. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/model.py +0 -0
  46. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/runner.py +0 -0
  47. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/extensions/store.py +0 -0
  48. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/hashing.py +0 -0
  49. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/__init__.py +0 -0
  50. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/errors.py +0 -0
  51. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
  52. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/model.py +0 -0
  53. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
  54. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
  55. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/mapping/record_mapping.py +0 -0
  56. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/planner.py +0 -0
  57. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/private_sqlite.py +0 -0
  58. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/py.typed +0 -0
  59. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/registry.py +0 -0
  60. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/safe_local_io.py +0 -0
  61. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/spec.py +0 -0
  62. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka/runtime/state.py +0 -0
  63. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/__main__.py +0 -0
  64. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/bundle.py +0 -0
  65. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/client.py +0 -0
  66. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/__init__.py +0 -0
  67. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/ai.py +0 -0
  68. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/auth.py +0 -0
  69. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/cloud_migrate.py +0 -0
  70. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/code.py +0 -0
  71. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/migrate.py +0 -0
  72. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/profiles.py +0 -0
  73. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/resources.py +0 -0
  74. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/skill.py +0 -0
  75. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/commands/workflows.py +0 -0
  76. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/config.py +0 -0
  77. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/main.py +0 -0
  78. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/mcp/__init__.py +0 -0
  79. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/mcp/client.py +0 -0
  80. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/mcp/render.py +0 -0
  81. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/mcp/server.py +0 -0
  82. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/output.py +0 -0
  83. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/runtime.py +0 -0
  84. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -0
  85. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_cli/state.py +0 -0
  86. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/__about__.py +0 -0
  87. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/__init__.py +0 -0
  88. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/credentials.py +0 -0
  89. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/errors.py +0 -0
  90. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/protocols.py +0 -0
  91. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/provisioning.py +0 -0
  92. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/py.typed +0 -0
  93. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/records.py +0 -0
  94. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/registration.py +0 -0
  95. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/src/sanka_connector/schema.py +0 -0
  96. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/conftest.py +0 -0
  97. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
  98. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
  99. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/api/views.py +0 -0
  100. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
  101. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
  102. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
  103. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_drf_project/manage.py +0 -0
  104. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
  105. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
  106. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_cli_developer_experience.py +0 -0
  107. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_code_bundle.py +0 -0
  108. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_code_commands.py +0 -0
  109. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_concurrency.py +0 -0
  110. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_connector_client.py +0 -0
  111. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_connector_host.py +0 -0
  112. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_credential_provider.py +0 -0
  113. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_engine_e2e.py +0 -0
  114. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_engine_execution_adoption.py +0 -0
  115. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
  116. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_engine_postgres_e2e.py +0 -0
  117. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_continuous.py +0 -0
  118. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_exact_scope.py +0 -0
  119. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_local_store.py +0 -0
  120. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_routes.py +0 -0
  121. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_scope.py +0 -0
  122. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_seam.py +0 -0
  123. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_execution_validate.py +0 -0
  124. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_extension_discovery.py +0 -0
  125. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_extension_lifecycle.py +0 -0
  126. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_extension_runner.py +0 -0
  127. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_extension_store.py +0 -0
  128. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_extension_wheel_acceptance.py +0 -0
  129. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_hashing.py +0 -0
  130. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_hosted_cli.py +0 -0
  131. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_mapping_candidates.py +0 -0
  132. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_mapping_owner_mapping.py +0 -0
  133. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_mapping_pending_relationships.py +0 -0
  134. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_mapping_record_mapping.py +0 -0
  135. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_planner.py +0 -0
  136. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_registry.py +0 -0
  137. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_safe_local_io.py +0 -0
  138. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_skill_commands.py +0 -0
  139. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_spec.py +0 -0
  140. {sanka_cli-0.2.4 → sanka_cli-0.2.6}/tests/test_state_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sanka-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Sanka command-line interface and migration runtime
5
5
  Project-URL: Repository, https://github.com/sankaHQ/sanka
6
6
  Project-URL: Issues, https://github.com/sankaHQ/sanka/issues
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.12
18
18
  Requires-Dist: click<9,>=8.1
19
+ Requires-Dist: cryptography<52,>=43
19
20
  Requires-Dist: httpx<1,>=0.27
20
21
  Requires-Dist: keyring<26,>=25
21
22
  Requires-Dist: packaging<27,>=24
@@ -107,6 +108,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
107
108
  their existing authentication requirements. Add `--json` to local lifecycle
108
109
  commands for the stable `sanka-cli/v1` response envelope.
109
110
 
111
+ ## Hosted repository runs
112
+
113
+ `sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
114
+ credit cap, and reads status, receipts, and retained artifacts. When enabled by
115
+ the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
116
+ compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
117
+ verifies signed evidence online or with an explicitly trusted offline key ring.
118
+ Every hosted command pins its workspace; paid operations require confirmation.
119
+ See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
120
+ verification scope, and revocation limits.
121
+
110
122
  ## Extension trust
111
123
 
112
124
  The manager verifies a trusted marketplace snapshot, manifest schema,
@@ -77,6 +77,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
77
77
  their existing authentication requirements. Add `--json` to local lifecycle
78
78
  commands for the stable `sanka-cli/v1` response envelope.
79
79
 
80
+ ## Hosted repository runs
81
+
82
+ `sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
83
+ credit cap, and reads status, receipts, and retained artifacts. When enabled by
84
+ the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
85
+ compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
86
+ verifies signed evidence online or with an explicitly trusted offline key ring.
87
+ Every hosted command pins its workspace; paid operations require confirmation.
88
+ See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
89
+ verification scope, and revocation limits.
90
+
80
91
  ## Extension trust
81
92
 
82
93
  The manager verifies a trusted marketplace snapshot, manifest schema,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sanka-cli"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Sanka command-line interface and migration runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -16,6 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "click>=8.1,<9",
19
+ "cryptography>=43,<52",
19
20
  "httpx>=0.27,<1",
20
21
  "keyring>=25,<26",
21
22
  "packaging>=24,<27",
@@ -207,6 +207,15 @@ def canonical_route_manifest(raw: Any) -> MappingRouteManifest:
207
207
  code="SANKA_MIGRATE_ROUTE_MANIFEST_INVALID",
208
208
  )
209
209
  seen.add(route_key)
210
+ identity_fields = item.get("identityFields", [])
211
+ if not isinstance(identity_fields, list) or any(
212
+ not isinstance(value, str) or not value.strip() or value != value.strip()
213
+ for value in identity_fields
214
+ ):
215
+ raise ExecutionFault(
216
+ "The saved Sanka route manifest contains invalid identity fields.",
217
+ code="SANKA_MIGRATE_ROUTE_MANIFEST_INVALID",
218
+ )
210
219
  normalized.append(
211
220
  {
212
221
  "routeKey": route_key,
@@ -215,6 +224,7 @@ def canonical_route_manifest(raw: Any) -> MappingRouteManifest:
215
224
  "sourceFilter": (
216
225
  _source_filter_payload(source_filter) if source_filter is not None else None
217
226
  ),
227
+ **({"identityFields": sorted(set(identity_fields))} if identity_fields else {}),
218
228
  }
219
229
  )
220
230
  return sorted(normalized, key=lambda row: str(row["routeKey"]))
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  __all__ = ["__version__"]
3
3
 
4
- __version__ = "0.2.4"
4
+ __version__ = "0.2.6"
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Public certificate input and verification helpers; no hosted execution code."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import unquote, urlsplit
12
+
13
+ from cryptography.exceptions import InvalidSignature
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
15
+
16
+
17
+ def canonical_bytes(value: Any) -> bytes:
18
+ return json.dumps(
19
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
20
+ ).encode("utf-8")
21
+
22
+
23
+ def read_json(path: Path, maximum: int = 192 * 1024) -> Any:
24
+ with path.open("rb") as stream:
25
+ data = stream.read(maximum + 1)
26
+ if len(data) > maximum:
27
+ raise ValueError("JSON file exceeds its size limit")
28
+ return json.loads(data)
29
+
30
+
31
+ def load_scenarios(path: Path) -> list[dict[str, Any]]:
32
+ cases = read_json(path, 48 * 1024)
33
+ if not isinstance(cases, list) or not 1 <= len(cases) <= 50:
34
+ raise ValueError("Cases must be a JSON array containing 1 to 50 requests")
35
+ seen, normalized = set(), []
36
+ for case in cases:
37
+ if not isinstance(case, dict) or set(case) - {"id", "method", "path", "json_body"}:
38
+ raise ValueError("Each case accepts only id, method, path and json_body")
39
+ identifier, method, value = case.get("id"), case.get("method"), case.get("path")
40
+ if (
41
+ not isinstance(identifier, str)
42
+ or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", identifier)
43
+ or identifier in seen
44
+ or method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}
45
+ or not isinstance(value, str)
46
+ or not 1 <= len(value) <= 1000
47
+ ):
48
+ raise ValueError("Each case needs a unique ID, supported method and local HTTP path")
49
+ seen.add(identifier)
50
+ parsed, decoded = urlsplit(value), unquote(value)
51
+ if (
52
+ not value.startswith("/")
53
+ or value.startswith("//")
54
+ or parsed.scheme
55
+ or parsed.netloc
56
+ or parsed.fragment
57
+ or "\\" in decoded
58
+ or any(ord(char) < 32 or ord(char) > 126 for char in decoded)
59
+ or ".." in decoded.split("?", 1)[0].split("/")
60
+ ):
61
+ raise ValueError("Case paths must be local ASCII HTTP paths without traversal")
62
+ body = case.get("json_body")
63
+ if len(canonical_bytes({"body": body})) > 8192 or (method == "GET" and body is not None):
64
+ raise ValueError("Case bodies are limited to 8 KiB; GET cannot have a body")
65
+ normalized.append({"id": identifier, "method": method, "path": value, "json_body": body})
66
+ if len(canonical_bytes(normalized)) > 47 * 1024:
67
+ raise ValueError("The normalized certificate scope exceeds its size limit")
68
+ return normalized
69
+
70
+
71
+ def verify_certificate(document: dict[str, Any], key_document: dict[str, Any]) -> dict[str, Any]:
72
+ """Verify using keys supplied by the caller's chosen trusted source only."""
73
+ signed = document.get("certificate", document)
74
+ key_document = key_document.get("data", key_document)
75
+ try:
76
+ payload = signed["payload"]
77
+ if (
78
+ signed["algorithm"] != "Ed25519"
79
+ or signed["encoding"] != "sorted-ascii-json-v1"
80
+ or payload["schema_version"] != "developer-certificate-v1"
81
+ or payload["issuer"] != "https://api-v2.sanka.com"
82
+ or not isinstance(payload, dict)
83
+ ):
84
+ raise ValueError("Unsupported certificate issuer or signature format")
85
+ matches = [row for row in key_document["keys"] if row["key_id"] == signed["key_id"]]
86
+ if len(matches) != 1 or matches[0].get("algorithm") != "Ed25519":
87
+ raise ValueError("The signing key is not in the selected trusted key ring")
88
+ key = Ed25519PublicKey.from_public_bytes(
89
+ base64.b64decode(matches[0]["public_key_base64"], validate=True)
90
+ )
91
+ key.verify(
92
+ base64.b64decode(signed["signature_base64"], validate=True), canonical_bytes(payload)
93
+ )
94
+ except InvalidSignature as error:
95
+ raise ValueError("Certificate signature is invalid") from error
96
+ except (KeyError, TypeError, AttributeError) as error:
97
+ raise ValueError("Certificate or trusted key ring has an invalid format") from error
98
+ return payload