fdai-deployment-cli 0.1.2__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 (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,797 @@
1
+ """Signed, exact-file-set offline kit construction and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import re
10
+ import stat
11
+ import sys
12
+ import sysconfig
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path, PurePosixPath
16
+ from typing import Final
17
+
18
+ from cryptography.exceptions import InvalidSignature
19
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
20
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
21
+
22
+ from fdai_deployment_cli.contracts import canonical_bytes, load_json_object
23
+
24
+ MANIFEST_NAME: Final = "offline-kit.json"
25
+ SIGNATURE_NAME: Final = "offline-kit.json.sig"
26
+ ROOT_MANIFEST_NAME: Final = "deployment-root.json"
27
+ ROOT_SIGNATURE_NAME: Final = "deployment-root.json.sig"
28
+ ROOT_SCHEMA_VERSION: Final = "fdai.deployment-root.v1"
29
+ _MAX_MANIFEST_BYTES = 4 * 1024 * 1024
30
+ _MAX_FILES = 20_000
31
+ _MAX_FILE_BYTES = 512 * 1024 * 1024
32
+ _MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024
33
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
34
+ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
35
+ _PROFILE_NAME = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
36
+ _LIBC_TAG = re.compile(
37
+ r"^(?P<name>[a-z0-9._-]+)-(?P<major>[0-9]+)\.(?P<minor>[0-9]+)"
38
+ r"(?:\.(?P<patch>[0-9]+))?$"
39
+ )
40
+ _PROFILE_CLOSURES: Final = frozenset({"connected", "offline", "appliance"})
41
+
42
+
43
+ class OfflineKitVerificationError(ValueError):
44
+ """The offline kit failed trust, compatibility, or content checks."""
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class OfflineKitVerification:
49
+ """Sanitized result of a successful kit verification."""
50
+
51
+ kit_version: str
52
+ cli_version: str
53
+ bundle_version: str
54
+ platform_tag: str
55
+ python_tag: str
56
+ libc_tag: str
57
+ file_count: int
58
+ total_bytes: int
59
+ manifest_digest: str
60
+ deployment_root_required: bool
61
+ terraform_binary: str
62
+ provider_mirror_prefix: str
63
+ deployment_bundle: str
64
+ rule_activation_profile: str | None
65
+ rule_activation_profile_id: str | None
66
+ rule_activation_profile_created_at: str | None
67
+ file_digests: tuple[tuple[str, str], ...]
68
+ file_sizes: tuple[tuple[str, int], ...]
69
+
70
+ def to_json(self) -> str:
71
+ """Return stable, non-secret machine output."""
72
+
73
+ return json.dumps(
74
+ {
75
+ "schema_version": "fdai.offline-kit-verification.v1",
76
+ "kit_version": self.kit_version,
77
+ "cli_version": self.cli_version,
78
+ "bundle_version": self.bundle_version,
79
+ "platform_tag": self.platform_tag,
80
+ "python_tag": self.python_tag,
81
+ "libc_tag": self.libc_tag,
82
+ "file_count": self.file_count,
83
+ "total_bytes": self.total_bytes,
84
+ "manifest_digest": self.manifest_digest,
85
+ "deployment_root_required": self.deployment_root_required,
86
+ },
87
+ sort_keys=True,
88
+ separators=(",", ":"),
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class RootManifestVerification:
94
+ """Sanitized result of a successful deployment-root verification."""
95
+
96
+ schema_version: str
97
+ profiles: tuple[str, ...]
98
+ kit_manifest_digest: str
99
+
100
+ def to_json(self) -> str:
101
+ return json.dumps(
102
+ {
103
+ "schema_version": "fdai.deployment-root-verification.v1",
104
+ "manifest_schema_version": self.schema_version,
105
+ "profiles": list(self.profiles),
106
+ "kit_manifest_digest": self.kit_manifest_digest,
107
+ },
108
+ sort_keys=True,
109
+ separators=(",", ":"),
110
+ )
111
+
112
+
113
+ def build_offline_kit_manifest(
114
+ root: Path,
115
+ *,
116
+ kit_version: str,
117
+ cli_version: str,
118
+ bundle_version: str,
119
+ platform_tag: str,
120
+ python_wheel: str,
121
+ deployment_bundle: str,
122
+ terraform_binary: str,
123
+ provider_mirror_prefix: str,
124
+ opa_binary: str,
125
+ sbom_path: str,
126
+ rule_activation_profile: str | None = None,
127
+ rule_activation_profile_id: str | None = None,
128
+ rule_activation_profile_created_at: str | None = None,
129
+ python_tag: str | None = None,
130
+ libc_tag: str | None = None,
131
+ deployment_root_required: bool = False,
132
+ ) -> bytes:
133
+ """Build canonical manifest bytes from the exact staged tree."""
134
+
135
+ required = {
136
+ "python_wheel": _relative_path(python_wheel),
137
+ "deployment_bundle": _relative_path(deployment_bundle),
138
+ "terraform_binary": _relative_path(terraform_binary),
139
+ "provider_mirror_prefix": _relative_path(provider_mirror_prefix),
140
+ "opa_binary": _relative_path(opa_binary),
141
+ "sbom_path": _relative_path(sbom_path),
142
+ }
143
+ activation_profile = (
144
+ _relative_path(rule_activation_profile) if rule_activation_profile is not None else None
145
+ )
146
+ if activation_profile is not None and not activation_profile.startswith("rule-activation/"):
147
+ raise OfflineKitVerificationError(
148
+ "offline kit Rule activation profile MUST be under rule-activation/"
149
+ )
150
+ profile_values = (
151
+ activation_profile,
152
+ rule_activation_profile_id,
153
+ rule_activation_profile_created_at,
154
+ )
155
+ if any(value is None for value in profile_values) and any(
156
+ value is not None for value in profile_values
157
+ ):
158
+ raise OfflineKitVerificationError(
159
+ "offline kit Rule activation profile path, id, and time MUST be supplied together"
160
+ )
161
+ if (
162
+ rule_activation_profile_id is not None
163
+ and _PROFILE_ID.fullmatch(rule_activation_profile_id) is None
164
+ ):
165
+ raise OfflineKitVerificationError("offline kit Rule activation profile id is invalid")
166
+ if rule_activation_profile_created_at is not None:
167
+ _absolute_timestamp(
168
+ rule_activation_profile_created_at,
169
+ "offline kit Rule activation profile time",
170
+ )
171
+ if not required["python_wheel"].startswith("python/"):
172
+ raise OfflineKitVerificationError("offline kit Python wheel MUST be under python/")
173
+ files, _sizes, _total = _scan_tree(root)
174
+ for label in (
175
+ "python_wheel",
176
+ "deployment_bundle",
177
+ "terraform_binary",
178
+ "opa_binary",
179
+ "sbom_path",
180
+ ):
181
+ if required[label] not in files:
182
+ raise OfflineKitVerificationError(f"offline kit is missing {label}")
183
+ if activation_profile is not None and activation_profile not in files:
184
+ raise OfflineKitVerificationError("offline kit is missing rule_activation_profile")
185
+ prefix = required["provider_mirror_prefix"].rstrip("/") + "/"
186
+ if not any(path.startswith(prefix) for path in files):
187
+ raise OfflineKitVerificationError("offline kit provider mirror is empty")
188
+ payload: dict[str, object] = {
189
+ "schema_version": "fdai.offline-kit.v1",
190
+ "kit_version": kit_version,
191
+ "cli_version": cli_version,
192
+ "bundle_version": bundle_version,
193
+ "platform_tag": platform_tag,
194
+ "python_tag": python_tag or _runtime_python_tag(),
195
+ "libc_tag": libc_tag or _runtime_libc_tag(),
196
+ **required,
197
+ "files": files,
198
+ }
199
+ if activation_profile is not None:
200
+ payload["rule_activation_profile"] = activation_profile
201
+ payload["rule_activation_profile_id"] = rule_activation_profile_id
202
+ payload["rule_activation_profile_created_at"] = rule_activation_profile_created_at
203
+ if deployment_root_required:
204
+ payload["deployment_root_required"] = True
205
+ return canonical_bytes(payload)
206
+
207
+
208
+ def build_root_manifest(
209
+ *,
210
+ kit_manifest_digest: str,
211
+ profiles: set[str] | list[str] | tuple[str, ...],
212
+ ) -> bytes:
213
+ """Build a canonical signed-root payload for supported artifact profiles."""
214
+
215
+ if _DIGEST.fullmatch(kit_manifest_digest) is None:
216
+ raise OfflineKitVerificationError("deployment root offline-kit digest is invalid")
217
+ profile_items = tuple(sorted(dict.fromkeys(profiles)))
218
+ if not profile_items:
219
+ raise OfflineKitVerificationError("deployment root profiles MUST be non-empty")
220
+ if any(item not in _PROFILE_CLOSURES for item in profile_items):
221
+ raise OfflineKitVerificationError("deployment root profiles contain an unsupported value")
222
+ return canonical_bytes(
223
+ {
224
+ "schema_version": ROOT_SCHEMA_VERSION,
225
+ "kit_manifest_digest": kit_manifest_digest,
226
+ "profiles": list(profile_items),
227
+ }
228
+ )
229
+
230
+
231
+ def verify_root_manifest(
232
+ root: Path,
233
+ *,
234
+ release_root_pem: bytes,
235
+ expected_profile: str | None = None,
236
+ ) -> RootManifestVerification:
237
+ """Verify the deployment root and its exact legacy kit-manifest binding."""
238
+
239
+ manifest_path = root / ROOT_MANIFEST_NAME
240
+ signature_path = root / ROOT_SIGNATURE_NAME
241
+ manifest = _read_regular(manifest_path, _MAX_MANIFEST_BYTES)
242
+ signature = _read_regular(signature_path, 64)
243
+ if len(signature) != 64:
244
+ raise OfflineKitVerificationError("deployment root signature MUST be 64 bytes")
245
+ _verify_signature(release_root_pem, manifest, signature)
246
+ payload = load_json_object(
247
+ manifest,
248
+ label="deployment root manifest",
249
+ max_bytes=_MAX_MANIFEST_BYTES,
250
+ )
251
+ if set(payload) != {"schema_version", "kit_manifest_digest", "profiles"}:
252
+ raise OfflineKitVerificationError("deployment root manifest schema does not match")
253
+ if payload["schema_version"] != ROOT_SCHEMA_VERSION:
254
+ raise OfflineKitVerificationError("deployment root manifest schema does not match")
255
+ if canonical_bytes(payload) != manifest:
256
+ raise OfflineKitVerificationError("deployment root manifest is not canonical")
257
+
258
+ kit_manifest_digest = payload["kit_manifest_digest"]
259
+ if not isinstance(kit_manifest_digest, str) or _DIGEST.fullmatch(kit_manifest_digest) is None:
260
+ raise OfflineKitVerificationError("deployment root offline-kit digest is invalid")
261
+ observed_kit_digest = hashlib.sha256(
262
+ _read_regular(root / MANIFEST_NAME, _MAX_MANIFEST_BYTES)
263
+ ).hexdigest()
264
+ if kit_manifest_digest != observed_kit_digest:
265
+ raise OfflineKitVerificationError("deployment root offline-kit digest does not match")
266
+
267
+ raw_profiles = payload["profiles"]
268
+ if not isinstance(raw_profiles, list) or not raw_profiles:
269
+ raise OfflineKitVerificationError("deployment root profiles MUST be a non-empty list")
270
+ profiles: list[str] = []
271
+ for item in raw_profiles:
272
+ if not isinstance(item, str) or item not in _PROFILE_CLOSURES:
273
+ raise OfflineKitVerificationError(
274
+ "deployment root profiles contain an unsupported value"
275
+ )
276
+ if item in profiles:
277
+ raise OfflineKitVerificationError("deployment root profiles MUST be unique")
278
+ profiles.append(item)
279
+ if profiles != sorted(profiles):
280
+ raise OfflineKitVerificationError("deployment root profiles MUST be sorted")
281
+ if expected_profile is not None:
282
+ if _PROFILE_NAME.fullmatch(expected_profile) is None:
283
+ raise OfflineKitVerificationError("deployment root expected profile is invalid")
284
+ if expected_profile not in profiles:
285
+ raise OfflineKitVerificationError("deployment root expected profile is not in profiles")
286
+ return RootManifestVerification(
287
+ schema_version=ROOT_SCHEMA_VERSION,
288
+ profiles=tuple(profiles),
289
+ kit_manifest_digest=kit_manifest_digest,
290
+ )
291
+
292
+
293
+ def verify_offline_kit(
294
+ root: Path,
295
+ *,
296
+ release_root_pem: bytes,
297
+ cli_version: str,
298
+ platform_tag: str,
299
+ python_tag: str | None = None,
300
+ libc_tag: str | None = None,
301
+ ) -> OfflineKitVerification:
302
+ """Verify signature before parsing, then compatibility, exact files, and digests."""
303
+
304
+ manifest_path = root / MANIFEST_NAME
305
+ signature_path = root / SIGNATURE_NAME
306
+ manifest = _read_regular(manifest_path, _MAX_MANIFEST_BYTES)
307
+ signature = _read_regular(signature_path, 64)
308
+ if len(signature) != 64:
309
+ raise OfflineKitVerificationError("offline kit signature MUST be 64 bytes")
310
+ _verify_signature(release_root_pem, manifest, signature)
311
+ try:
312
+ payload = load_json_object(
313
+ manifest,
314
+ label="offline kit manifest",
315
+ max_bytes=_MAX_MANIFEST_BYTES,
316
+ )
317
+ expected = {
318
+ "schema_version",
319
+ "kit_version",
320
+ "cli_version",
321
+ "bundle_version",
322
+ "platform_tag",
323
+ "python_tag",
324
+ "libc_tag",
325
+ "python_wheel",
326
+ "deployment_bundle",
327
+ "terraform_binary",
328
+ "provider_mirror_prefix",
329
+ "opa_binary",
330
+ "sbom_path",
331
+ "files",
332
+ }
333
+ activation_fields = {
334
+ "rule_activation_profile",
335
+ "rule_activation_profile_id",
336
+ "rule_activation_profile_created_at",
337
+ }
338
+ allowed = expected | activation_fields | {"deployment_root_required"}
339
+ keys = set(payload)
340
+ if (
341
+ not expected.issubset(keys)
342
+ or not keys.issubset(allowed)
343
+ or (keys & activation_fields and not activation_fields.issubset(keys))
344
+ or payload["schema_version"] != "fdai.offline-kit.v1"
345
+ ):
346
+ raise OfflineKitVerificationError("offline kit manifest schema does not match")
347
+ deployment_root_required = payload.get("deployment_root_required", False)
348
+ if not isinstance(deployment_root_required, bool):
349
+ raise OfflineKitVerificationError("offline kit deployment root requirement is invalid")
350
+ if payload["cli_version"] != cli_version:
351
+ raise OfflineKitVerificationError("offline kit CLI version does not match")
352
+ if payload["platform_tag"] != platform_tag:
353
+ raise OfflineKitVerificationError("offline kit platform does not match")
354
+ if payload["python_tag"] != (python_tag or _runtime_python_tag()):
355
+ raise OfflineKitVerificationError("offline kit Python ABI does not match")
356
+ _require_compatible_libc(
357
+ _payload_text(payload, "libc_tag"),
358
+ libc_tag or _runtime_libc_tag(),
359
+ )
360
+ files_value = payload["files"]
361
+ if not isinstance(files_value, dict) or not files_value:
362
+ raise OfflineKitVerificationError("offline kit files MUST be a non-empty object")
363
+ declared: dict[str, str] = {}
364
+ for raw_path, raw_digest in files_value.items():
365
+ if not isinstance(raw_path, str) or not isinstance(raw_digest, str):
366
+ raise OfflineKitVerificationError("offline kit file entries are invalid")
367
+ path = _relative_path(raw_path)
368
+ if _DIGEST.fullmatch(raw_digest) is None:
369
+ raise OfflineKitVerificationError("offline kit file digest is invalid")
370
+ declared[path] = raw_digest
371
+ required_paths = (
372
+ "python_wheel",
373
+ "deployment_bundle",
374
+ "terraform_binary",
375
+ "opa_binary",
376
+ "sbom_path",
377
+ )
378
+ for field in required_paths:
379
+ value = payload[field]
380
+ if not isinstance(value, str) or _relative_path(value) not in declared:
381
+ raise OfflineKitVerificationError(f"offline kit {field} is not declared")
382
+ activation_profile_value = payload.get("rule_activation_profile")
383
+ activation_profile_id_value = payload.get("rule_activation_profile_id")
384
+ activation_profile_time_value = payload.get("rule_activation_profile_created_at")
385
+ activation_profile = None
386
+ activation_profile_id = None
387
+ activation_profile_created_at = None
388
+ if activation_profile_value is not None:
389
+ if not isinstance(activation_profile_value, str):
390
+ raise OfflineKitVerificationError(
391
+ "offline kit Rule activation profile path is invalid"
392
+ )
393
+ activation_profile = _relative_path(activation_profile_value)
394
+ if (
395
+ not activation_profile.startswith("rule-activation/")
396
+ or activation_profile not in declared
397
+ ):
398
+ raise OfflineKitVerificationError(
399
+ "offline kit Rule activation profile is not declared"
400
+ )
401
+ if (
402
+ not isinstance(activation_profile_id_value, str)
403
+ or _PROFILE_ID.fullmatch(activation_profile_id_value) is None
404
+ ):
405
+ raise OfflineKitVerificationError(
406
+ "offline kit Rule activation profile id is invalid"
407
+ )
408
+ activation_profile_id = activation_profile_id_value
409
+ if not isinstance(activation_profile_time_value, str):
410
+ raise OfflineKitVerificationError(
411
+ "offline kit Rule activation profile time is invalid"
412
+ )
413
+ _absolute_timestamp(
414
+ activation_profile_time_value,
415
+ "offline kit Rule activation profile time",
416
+ )
417
+ activation_profile_created_at = activation_profile_time_value
418
+ elif activation_profile_id_value is not None or activation_profile_time_value is not None:
419
+ raise OfflineKitVerificationError(
420
+ "offline kit Rule activation profile id has no profile artifact"
421
+ )
422
+ if not _payload_text(payload, "python_wheel").startswith("python/"):
423
+ raise OfflineKitVerificationError("offline kit Python wheel MUST be under python/")
424
+ prefix_value = payload["provider_mirror_prefix"]
425
+ if not isinstance(prefix_value, str):
426
+ raise OfflineKitVerificationError("offline kit provider mirror prefix is invalid")
427
+ prefix = _relative_path(prefix_value).rstrip("/") + "/"
428
+ if not any(path.startswith(prefix) for path in declared):
429
+ raise OfflineKitVerificationError("offline kit provider mirror is empty")
430
+ observed, sizes, total = _scan_tree(root)
431
+ if observed != declared:
432
+ raise OfflineKitVerificationError("offline kit exact file set or digest does not match")
433
+ _verify_sbom(root, payload, declared)
434
+ if canonical_bytes(payload) != manifest:
435
+ raise OfflineKitVerificationError("offline kit manifest is not canonical")
436
+ if (
437
+ deployment_root_required
438
+ or os.path.lexists(root / ROOT_MANIFEST_NAME)
439
+ or os.path.lexists(root / ROOT_SIGNATURE_NAME)
440
+ ):
441
+ verify_root_manifest(
442
+ root,
443
+ release_root_pem=release_root_pem,
444
+ )
445
+ return OfflineKitVerification(
446
+ kit_version=_payload_text(payload, "kit_version"),
447
+ cli_version=_payload_text(payload, "cli_version"),
448
+ bundle_version=_payload_text(payload, "bundle_version"),
449
+ platform_tag=_payload_text(payload, "platform_tag"),
450
+ python_tag=_payload_text(payload, "python_tag"),
451
+ libc_tag=_payload_text(payload, "libc_tag"),
452
+ file_count=len(observed),
453
+ total_bytes=total,
454
+ manifest_digest=hashlib.sha256(manifest).hexdigest(),
455
+ deployment_root_required=deployment_root_required,
456
+ terraform_binary=_payload_text(payload, "terraform_binary"),
457
+ provider_mirror_prefix=_payload_text(payload, "provider_mirror_prefix"),
458
+ deployment_bundle=_payload_text(payload, "deployment_bundle"),
459
+ rule_activation_profile=activation_profile,
460
+ rule_activation_profile_id=activation_profile_id,
461
+ rule_activation_profile_created_at=activation_profile_created_at,
462
+ file_digests=tuple(sorted(declared.items())),
463
+ file_sizes=tuple(sorted(sizes.items())),
464
+ )
465
+ except OfflineKitVerificationError:
466
+ raise
467
+ except (KeyError, TypeError, ValueError) as exc:
468
+ raise OfflineKitVerificationError("offline kit manifest is invalid") from exc
469
+
470
+
471
+ def _scan_tree(root: Path) -> tuple[dict[str, str], dict[str, int], int]:
472
+ if root.is_symlink() or not root.is_dir():
473
+ raise OfflineKitVerificationError("offline kit root MUST be a directory")
474
+ files: dict[str, str] = {}
475
+ sizes: dict[str, int] = {}
476
+ total = 0
477
+ for directory, directories, names in os.walk(root, followlinks=False):
478
+ base = Path(directory)
479
+ for name in directories:
480
+ candidate = base / name
481
+ if candidate.is_symlink():
482
+ raise OfflineKitVerificationError("offline kit MUST NOT contain symlinks")
483
+ for name in names:
484
+ candidate = base / name
485
+ relative = candidate.relative_to(root).as_posix()
486
+ if relative in {
487
+ MANIFEST_NAME,
488
+ SIGNATURE_NAME,
489
+ ROOT_MANIFEST_NAME,
490
+ ROOT_SIGNATURE_NAME,
491
+ }:
492
+ continue
493
+ if candidate.is_symlink():
494
+ raise OfflineKitVerificationError("offline kit MUST NOT contain symlinks")
495
+ if len(files) >= _MAX_FILES:
496
+ raise OfflineKitVerificationError("offline kit exceeds its file count limit")
497
+ details = candidate.lstat()
498
+ if not stat.S_ISREG(details.st_mode):
499
+ raise OfflineKitVerificationError("offline kit MUST contain only regular files")
500
+ if details.st_size > _MAX_FILE_BYTES:
501
+ raise OfflineKitVerificationError("offline kit file exceeds its size limit")
502
+ total += details.st_size
503
+ if total > _MAX_TOTAL_BYTES:
504
+ raise OfflineKitVerificationError("offline kit exceeds its total size limit")
505
+ files[relative] = _sha256_nofollow(candidate, expected=details)
506
+ sizes[relative] = details.st_size
507
+ return dict(sorted(files.items())), dict(sorted(sizes.items())), total
508
+
509
+
510
+ @dataclass(frozen=True, slots=True)
511
+ class MaterializedOfflineArtifacts:
512
+ """Private verified copies used after kit verification."""
513
+
514
+ terraform_binary: Path
515
+ provider_mirror: Path
516
+ deployment_bundle: Path
517
+ python_wheels: Path
518
+
519
+
520
+ def materialize_verified_artifacts(
521
+ root: Path,
522
+ verification: OfflineKitVerification,
523
+ destination: Path,
524
+ *,
525
+ include_all: bool = False,
526
+ ) -> MaterializedOfflineArtifacts:
527
+ """Snapshot signed inputs, optionally including the complete release payload."""
528
+
529
+ if destination.exists():
530
+ raise OfflineKitVerificationError("offline artifact destination already exists")
531
+ destination.mkdir(parents=True, mode=0o700)
532
+ destination.chmod(0o700)
533
+ digests = dict(verification.file_digests)
534
+ sizes = dict(verification.file_sizes)
535
+ prefix = verification.provider_mirror_prefix.rstrip("/") + "/"
536
+ python_prefix = "python/"
537
+ selected = {
538
+ verification.terraform_binary,
539
+ verification.deployment_bundle,
540
+ *(path for path in digests if path.startswith(prefix)),
541
+ *(path for path in digests if path.startswith(python_prefix)),
542
+ }
543
+ if include_all:
544
+ selected = set(digests)
545
+ total_bytes = 0
546
+ for relative in sorted(selected):
547
+ expected = digests.get(relative)
548
+ expected_size = sizes.get(relative)
549
+ if expected is None or expected_size is None:
550
+ raise OfflineKitVerificationError("verified offline artifact is not declared")
551
+ total_bytes += expected_size
552
+ if total_bytes > _MAX_TOTAL_BYTES:
553
+ raise OfflineKitVerificationError("offline artifact snapshot exceeds its size limit")
554
+ target = destination / relative
555
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
556
+ _copy_verified_file(
557
+ root / relative,
558
+ target,
559
+ expected_digest=expected,
560
+ expected_size=expected_size,
561
+ )
562
+ terraform = destination / verification.terraform_binary
563
+ terraform.chmod(0o700)
564
+ # Pinned kubectl, kubelogin, and opa run from bin/ on the execution host.
565
+ for tool in sorted((destination / "bin").glob("*")):
566
+ if tool.is_file() and not tool.is_symlink():
567
+ tool.chmod(0o700)
568
+ return MaterializedOfflineArtifacts(
569
+ terraform_binary=terraform,
570
+ provider_mirror=destination / verification.provider_mirror_prefix,
571
+ deployment_bundle=destination / verification.deployment_bundle,
572
+ python_wheels=destination / "python",
573
+ )
574
+
575
+
576
+ def _copy_verified_file(
577
+ source: Path,
578
+ target: Path,
579
+ *,
580
+ expected_digest: str,
581
+ expected_size: int,
582
+ ) -> None:
583
+ details = source.lstat()
584
+ if not stat.S_ISREG(details.st_mode) or details.st_size != expected_size:
585
+ raise OfflineKitVerificationError("verified offline artifact size changed")
586
+ if expected_size > _MAX_FILE_BYTES:
587
+ raise OfflineKitVerificationError("verified offline artifact exceeds its size limit")
588
+ descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
589
+ output_descriptor = os.open(
590
+ target,
591
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
592
+ 0o600,
593
+ )
594
+ digest = hashlib.sha256()
595
+ copied = 0
596
+ try:
597
+ with (
598
+ os.fdopen(descriptor, "rb") as input_stream,
599
+ os.fdopen(output_descriptor, "wb") as output_stream,
600
+ ):
601
+ opened = os.fstat(input_stream.fileno())
602
+ _require_same_file(details, opened)
603
+ for chunk in iter(lambda: input_stream.read(1024 * 1024), b""):
604
+ copied += len(chunk)
605
+ if copied > expected_size:
606
+ raise OfflineKitVerificationError("verified offline artifact size changed")
607
+ digest.update(chunk)
608
+ output_stream.write(chunk)
609
+ output_stream.flush()
610
+ os.fsync(output_stream.fileno())
611
+ _require_same_file(opened, os.fstat(input_stream.fileno()))
612
+ except BaseException:
613
+ target.unlink(missing_ok=True)
614
+ raise
615
+ if digest.hexdigest() != expected_digest:
616
+ target.unlink(missing_ok=True)
617
+ raise OfflineKitVerificationError("verified offline artifact digest changed")
618
+ if copied != expected_size:
619
+ target.unlink(missing_ok=True)
620
+ raise OfflineKitVerificationError("verified offline artifact size changed")
621
+
622
+
623
+ def _sha256_nofollow(path: Path, *, expected: os.stat_result) -> str:
624
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
625
+ digest = hashlib.sha256()
626
+ copied = 0
627
+ with os.fdopen(descriptor, "rb") as stream:
628
+ opened = os.fstat(stream.fileno())
629
+ _require_same_file(expected, opened)
630
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
631
+ copied += len(chunk)
632
+ if copied > expected.st_size:
633
+ raise OfflineKitVerificationError("offline kit file grew during verification")
634
+ digest.update(chunk)
635
+ _require_same_file(opened, os.fstat(stream.fileno()))
636
+ if copied != expected.st_size:
637
+ raise OfflineKitVerificationError("offline kit file size changed during verification")
638
+ return digest.hexdigest()
639
+
640
+
641
+ def _require_same_file(expected: os.stat_result, observed: os.stat_result) -> None:
642
+ if (
643
+ not stat.S_ISREG(observed.st_mode)
644
+ or expected.st_dev != observed.st_dev
645
+ or expected.st_ino != observed.st_ino
646
+ or expected.st_size != observed.st_size
647
+ or expected.st_mtime_ns != observed.st_mtime_ns
648
+ ):
649
+ raise OfflineKitVerificationError("offline kit file changed during verification")
650
+
651
+
652
+ def _read_regular(path: Path, maximum: int) -> bytes:
653
+ try:
654
+ details = path.lstat()
655
+ except OSError as exc:
656
+ raise OfflineKitVerificationError("offline kit metadata is unavailable") from exc
657
+ if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
658
+ raise OfflineKitVerificationError("offline kit metadata is invalid")
659
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
660
+ with os.fdopen(descriptor, "rb") as stream:
661
+ opened = os.fstat(stream.fileno())
662
+ _require_same_file(details, opened)
663
+ payload = stream.read(maximum + 1)
664
+ _require_same_file(opened, os.fstat(stream.fileno()))
665
+ return payload
666
+
667
+
668
+ def _verify_signature(public_pem: bytes, document: bytes, signature: bytes) -> None:
669
+ try:
670
+ key = load_pem_public_key(public_pem)
671
+ except (TypeError, ValueError) as exc:
672
+ raise OfflineKitVerificationError("offline kit release root is invalid") from exc
673
+ if not isinstance(key, Ed25519PublicKey):
674
+ raise OfflineKitVerificationError("offline kit release root MUST be Ed25519")
675
+ try:
676
+ key.verify(signature, document)
677
+ except InvalidSignature as exc:
678
+ raise OfflineKitVerificationError("offline kit signature is invalid") from exc
679
+
680
+
681
+ def _verify_sbom(
682
+ root: Path,
683
+ manifest: dict[str, object],
684
+ declared: dict[str, str],
685
+ ) -> None:
686
+ sbom_value = manifest["sbom_path"]
687
+ if not isinstance(sbom_value, str):
688
+ raise OfflineKitVerificationError("offline kit sbom_path is invalid")
689
+ sbom_path = _relative_path(sbom_value)
690
+ sbom = load_json_object(
691
+ _read_regular(root / sbom_path, 16 * 1024 * 1024),
692
+ label="offline kit SBOM",
693
+ max_bytes=16 * 1024 * 1024,
694
+ )
695
+ if sbom.get("bomFormat") != "CycloneDX" or sbom.get("specVersion") != "1.5":
696
+ raise OfflineKitVerificationError("offline kit SBOM format is unsupported")
697
+ components = sbom.get("components")
698
+ if not isinstance(components, list):
699
+ raise OfflineKitVerificationError("offline kit SBOM components are invalid")
700
+ covered: dict[str, str] = {}
701
+ for component in components:
702
+ if not isinstance(component, dict):
703
+ raise OfflineKitVerificationError("offline kit SBOM component is invalid")
704
+ name = component.get("name")
705
+ hashes = component.get("hashes")
706
+ if not isinstance(name, str) or not isinstance(hashes, list):
707
+ raise OfflineKitVerificationError("offline kit SBOM component is invalid")
708
+ sha256_values = [
709
+ item.get("content")
710
+ for item in hashes
711
+ if isinstance(item, dict) and item.get("alg") == "SHA-256"
712
+ ]
713
+ if len(sha256_values) != 1 or not isinstance(sha256_values[0], str):
714
+ raise OfflineKitVerificationError("offline kit SBOM SHA-256 is invalid")
715
+ path = _relative_path(name)
716
+ if path in covered:
717
+ raise OfflineKitVerificationError("offline kit SBOM contains duplicate paths")
718
+ covered[path] = sha256_values[0]
719
+ expected = {path: digest for path, digest in declared.items() if path != sbom_path}
720
+ if covered != expected:
721
+ raise OfflineKitVerificationError("offline kit SBOM coverage does not match")
722
+
723
+
724
+ def _relative_path(value: str) -> str:
725
+ path = PurePosixPath(value)
726
+ if (
727
+ not value
728
+ or value.startswith("/")
729
+ or "\\" in value
730
+ or any(part in {"", ".", ".."} for part in path.parts)
731
+ ):
732
+ raise OfflineKitVerificationError("offline kit path is invalid")
733
+ return path.as_posix()
734
+
735
+
736
+ def _payload_text(value: dict[str, object], field: str) -> str:
737
+ item = value[field]
738
+ if not isinstance(item, str) or not item:
739
+ raise OfflineKitVerificationError(f"offline kit {field} MUST be text")
740
+ return item
741
+
742
+
743
+ def _absolute_timestamp(value: str, label: str) -> datetime:
744
+ try:
745
+ parsed = datetime.fromisoformat(value)
746
+ except ValueError as exc:
747
+ raise OfflineKitVerificationError(f"{label} is invalid") from exc
748
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
749
+ raise OfflineKitVerificationError(f"{label} MUST be timezone-aware")
750
+ return parsed
751
+
752
+
753
+ def _runtime_python_tag() -> str:
754
+ soabi = sysconfig.get_config_var("SOABI")
755
+ if not isinstance(soabi, str) or re.fullmatch(r"[A-Za-z0-9._-]+", soabi) is None:
756
+ raise OfflineKitVerificationError("runtime Python ABI identity is unavailable")
757
+ return (
758
+ f"{sys.implementation.name}-{sys.version_info.major}{sys.version_info.minor}-"
759
+ f"{soabi.casefold()}"
760
+ )
761
+
762
+
763
+ def _runtime_libc_tag() -> str:
764
+ name, version = platform.libc_ver()
765
+ normalized_name = name.casefold().strip()
766
+ normalized_version = version.casefold().strip()
767
+ if (
768
+ re.fullmatch(r"[a-z0-9._-]+", normalized_name) is None
769
+ or re.fullmatch(r"[a-z0-9._-]+", normalized_version) is None
770
+ ):
771
+ raise OfflineKitVerificationError("runtime libc identity is unavailable")
772
+ return f"{normalized_name}-{normalized_version}"
773
+
774
+
775
+ def _require_compatible_libc(required: str, observed: str) -> None:
776
+ """Require exact non-glibc identity or a glibc runtime at least as new as the kit."""
777
+
778
+ if required == observed:
779
+ return
780
+ required_match = _LIBC_TAG.fullmatch(required)
781
+ observed_match = _LIBC_TAG.fullmatch(observed)
782
+ if (
783
+ required_match is None
784
+ or observed_match is None
785
+ or required_match.group("name") != "glibc"
786
+ or observed_match.group("name") != "glibc"
787
+ or _libc_version(observed_match) < _libc_version(required_match)
788
+ ):
789
+ raise OfflineKitVerificationError("offline kit libc is incompatible")
790
+
791
+
792
+ def _libc_version(match: re.Match[str]) -> tuple[int, int, int]:
793
+ return (
794
+ int(match.group("major")),
795
+ int(match.group("minor")),
796
+ int(match.group("patch") or 0),
797
+ )