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,353 @@
1
+ """Validate one OCI-layout image without extracting layers or granting provenance trust.
2
+
3
+ The layout may retain one family-consistent OCI image or Docker schema 2 image
4
+ manifest. Only uncompressed, regular USTAR-compatible archives are supported.
5
+ The bounded archive is retained as immutable private process memory; streaming
6
+ never reopens its original path. No snapshot, layer, or credential is written.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import tarfile
15
+ from collections.abc import Iterator
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import cast
19
+
20
+ from fdai_deployment_cli import offline_kit
21
+
22
+ OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json"
23
+ OCI_CONFIG = "application/vnd.oci.image.config.v1+json"
24
+ OCI_INDEX = "application/vnd.oci.image.index.v1+json"
25
+ DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"
26
+ DOCKER_CONFIG = "application/vnd.docker.container.image.v1+json"
27
+ _OCI_LAYERS = {
28
+ "application/vnd.oci.image.layer.v1.tar",
29
+ "application/vnd.oci.image.layer.v1.tar+gzip",
30
+ "application/vnd.oci.image.layer.v1.tar+zstd",
31
+ }
32
+ _DOCKER_LAYERS = {"application/vnd.docker.image.rootfs.diff.tar.gzip"}
33
+ _IMAGE_FAMILIES = {
34
+ OCI_MANIFEST: ({OCI_CONFIG}, _OCI_LAYERS),
35
+ DOCKER_MANIFEST: ({DOCKER_CONFIG}, _DOCKER_LAYERS),
36
+ }
37
+ _PLATFORMS = {"linux-x86_64": "amd64", "linux-aarch64": "arm64"}
38
+ _DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
39
+ _BLOB = re.compile(r"blobs/sha256/[0-9a-f]{64}")
40
+ _REVISION = "org.opencontainers.image.revision"
41
+ _CHUNK = 1024 * 1024
42
+
43
+
44
+ class OciArchiveError(ValueError):
45
+ """Sanitized structural-validation failure; stage and status are stable codes."""
46
+
47
+ def __init__(self, stage: str, status: str = "invalid") -> None:
48
+ self.stage = stage
49
+ self.status = status
50
+ super().__init__(f"OCI archive {stage}: {status}")
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class OciDescriptor:
55
+ """A hash-checked blob's media type, size, archive path, and byte offset."""
56
+
57
+ digest: str
58
+ size: int
59
+ media_type: str
60
+ path: str
61
+ offset: int
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class VerifiedOciImage[Revision: (str, None)]:
66
+ """Content validation, not attestation or authority; retains a bounded snapshot."""
67
+
68
+ manifest: OciDescriptor
69
+ config: OciDescriptor
70
+ layers: tuple[OciDescriptor, ...]
71
+ source_commit: Revision
72
+ platform_tag: str
73
+ archive_sha256: str
74
+ _snapshot: bytes = field(repr=False, compare=False)
75
+
76
+ def iter_bytes(self, descriptor: OciDescriptor, *, chunk_size: int = _CHUNK) -> Iterator[bytes]:
77
+ """Stream only a verified descriptor, in at most one-MiB immutable chunks."""
78
+
79
+ _require(descriptor in (self.manifest, self.config, *self.layers), "descriptor")
80
+ _require(type(chunk_size) is int and 0 < chunk_size <= _CHUNK, "descriptor")
81
+ end = descriptor.offset + descriptor.size
82
+ for start in range(descriptor.offset, end, chunk_size):
83
+ yield self._snapshot[start : min(end, start + chunk_size)]
84
+
85
+
86
+ def validate_oci_archive(
87
+ path: Path,
88
+ *,
89
+ expected_archive_sha256: str,
90
+ expected_manifest_digest: str,
91
+ expected_source_commit: str,
92
+ expected_platform_tag: str,
93
+ ) -> VerifiedOciImage[str]:
94
+ """Bind an immutable image snapshot to the caller's four explicit assertions.
95
+
96
+ Archive, member, count, and total limits inherit offline-kit ceilings. The
97
+ archive itself is at most the offline-kit per-file limit (512 MiB today).
98
+ Every blob is SHA-256 checked; compressed layer contents and rootfs diff_ids
99
+ are not interpreted. One coherent OCI or Docker schema 2 media family is
100
+ accepted. Nested indexes, multiple images, mixed media families, foreign
101
+ layers, links, extensions, duplicate paths/JSON keys, and extra blobs fail closed.
102
+ Source labels are content assertions, not independently trusted provenance.
103
+ Raises OciArchiveError without including paths or untrusted payloads.
104
+ """
105
+ _require(
106
+ isinstance(expected_source_commit, str)
107
+ and bool(re.fullmatch(r"[0-9a-fA-F]{40}", expected_source_commit)),
108
+ "revision",
109
+ )
110
+ return _validate_archive(
111
+ path,
112
+ expected_archive_sha256=expected_archive_sha256,
113
+ expected_manifest_digest=expected_manifest_digest,
114
+ expected_source_commit=expected_source_commit,
115
+ expected_platform_tag=expected_platform_tag,
116
+ )
117
+
118
+
119
+ def validate_dependency_oci_archive(
120
+ path: Path,
121
+ *,
122
+ expected_archive_sha256: str,
123
+ expected_manifest_digest: str,
124
+ expected_platform_tag: str,
125
+ ) -> VerifiedOciImage[None]:
126
+ """Validate dependency content and platform without asserting an FDAI source revision.
127
+
128
+ All archive, blob, manifest and platform bounds are identical to service validation.
129
+ Vendor revision labels are not source attestation. The returned source_commit is None.
130
+ """
131
+ return _validate_archive(
132
+ path,
133
+ expected_archive_sha256=expected_archive_sha256,
134
+ expected_manifest_digest=expected_manifest_digest,
135
+ expected_source_commit=None,
136
+ expected_platform_tag=expected_platform_tag,
137
+ )
138
+
139
+
140
+ def _validate_archive[Revision: (str, None)](
141
+ path: Path,
142
+ *,
143
+ expected_archive_sha256: str,
144
+ expected_manifest_digest: str,
145
+ expected_source_commit: Revision,
146
+ expected_platform_tag: str,
147
+ ) -> VerifiedOciImage[Revision]:
148
+
149
+ try:
150
+ _require(bool(re.fullmatch(r"[0-9a-f]{64}", expected_archive_sha256)), "archive")
151
+ _require(bool(_DIGEST.fullmatch(expected_manifest_digest)), "manifest")
152
+ _require(expected_platform_tag in _PLATFORMS, "platform", "unsupported")
153
+ snapshot = offline_kit._read_regular(path, offline_kit._MAX_FILE_BYTES)
154
+ _require(len(snapshot) <= offline_kit._MAX_FILE_BYTES, "archive", "limit")
155
+ _require(
156
+ hashlib.sha256(snapshot).hexdigest() == expected_archive_sha256,
157
+ "archive",
158
+ "digest-mismatch",
159
+ )
160
+ entries = _scan(snapshot)
161
+ layout = _json(snapshot, entries["oci-layout"])
162
+ _require(layout == {"imageLayoutVersion": "1.0.0"}, "layout", "unsupported")
163
+ index = _json(snapshot, entries["index.json"])
164
+ _schema(index, OCI_INDEX, "index")
165
+ manifests = index.get("manifests")
166
+ _require(isinstance(manifests, list) and len(manifests) == 1, "index", "unsupported")
167
+ selected = _object(cast(list[object], manifests)[0])
168
+ manifest = _descriptor(selected, entries, set(_IMAGE_FAMILIES), "manifest")
169
+ _require(manifest.digest == expected_manifest_digest, "manifest", "digest-mismatch")
170
+ config_media_types, layer_media_types = _IMAGE_FAMILIES[manifest.media_type]
171
+ image = _json(snapshot, (manifest.offset, manifest.size))
172
+ _schema(image, manifest.media_type, "manifest")
173
+ config = _descriptor(image.get("config"), entries, config_media_types, "config")
174
+ layer_values = image.get("layers")
175
+ _require(isinstance(layer_values, list), "layers")
176
+ layers = tuple(
177
+ _descriptor(value, entries, layer_media_types, "layer")
178
+ for value in cast(list[object], layer_values)
179
+ )
180
+ configuration = _json(snapshot, (config.offset, config.size))
181
+ architecture = _PLATFORMS[expected_platform_tag]
182
+ _platform(configuration, architecture)
183
+ if "platform" in selected:
184
+ _platform(_object(selected["platform"]), architecture)
185
+ rootfs = _object(configuration.get("rootfs"))
186
+ diff_ids = rootfs.get("diff_ids")
187
+ _require(rootfs.get("type") == "layers" and isinstance(diff_ids, list), "config")
188
+ _require(len(cast(list[object], diff_ids)) == len(layers), "config")
189
+ for digest in cast(list[object], diff_ids):
190
+ _require(isinstance(digest, str) and bool(_DIGEST.fullmatch(digest)), "config")
191
+ if expected_source_commit is not None:
192
+ labels = _object(configuration.get("config", {})).get("Labels", {})
193
+ _revision(
194
+ (
195
+ labels,
196
+ selected.get("annotations", {}),
197
+ image.get("annotations", {}),
198
+ index.get("annotations", {}),
199
+ ),
200
+ expected_source_commit,
201
+ )
202
+ expected_paths = {"oci-layout", "index.json", manifest.path, config.path}
203
+ expected_paths.update(layer.path for layer in layers)
204
+ _require(set(entries) == expected_paths, "archive", "extra-content")
205
+ return VerifiedOciImage(
206
+ manifest,
207
+ config,
208
+ layers,
209
+ expected_source_commit,
210
+ expected_platform_tag,
211
+ expected_archive_sha256,
212
+ snapshot,
213
+ )
214
+ except OciArchiveError:
215
+ raise
216
+ except (OSError, ValueError, TypeError, KeyError, RecursionError, tarfile.TarError):
217
+ raise OciArchiveError("archive", "invalid-or-unavailable") from None
218
+
219
+
220
+ def _require(condition: bool, stage: str, status: str = "invalid") -> None:
221
+ if not condition:
222
+ raise OciArchiveError(stage, status)
223
+
224
+
225
+ def _scan(snapshot: bytes) -> dict[str, tuple[int, int]]:
226
+ """Parse physical headers, not TarFile's extension-skipping logical members."""
227
+
228
+ _require(len(snapshot) % 512 == 0, "archive")
229
+ entries: dict[str, tuple[int, int]] = {}
230
+ seen: set[str] = set()
231
+ position = total = 0
232
+ while position + 512 <= len(snapshot):
233
+ header = snapshot[position : position + 512]
234
+ if header == bytes(512):
235
+ _require(len(snapshot) - position >= 1024, "archive")
236
+ _require(not any(memoryview(snapshot)[position:]), "archive", "trailing-content")
237
+ _require({"oci-layout", "index.json"} <= entries.keys(), "layout")
238
+ return entries
239
+ member = tarfile.TarInfo.frombuf(header, encoding="ascii", errors="strict")
240
+ _require(
241
+ member.type in {tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE},
242
+ "archive",
243
+ "unsupported-member",
244
+ )
245
+ _require(not member.linkname and member.sparse is None, "archive", "unsupported-member")
246
+ name = member.name
247
+ _require(name not in seen, "archive", "duplicate-path")
248
+ seen.add(name)
249
+ _require(len(seen) <= offline_kit._MAX_FILES, "archive", "limit")
250
+ _require(0 <= member.size <= offline_kit._MAX_FILE_BYTES, "archive", "limit")
251
+ if member.isdir():
252
+ _require(name in {"blobs", "blobs/sha256"} and member.size == 0, "archive")
253
+ else:
254
+ _require(
255
+ name in {"oci-layout", "index.json"} or bool(_BLOB.fullmatch(name)),
256
+ "archive",
257
+ "invalid-path",
258
+ )
259
+ entries[name] = (position + 512, member.size)
260
+ total += member.size
261
+ _require(total <= offline_kit._MAX_TOTAL_BYTES, "archive", "limit")
262
+ start = position + 512
263
+ end = start + member.size
264
+ position = start + ((member.size + 511) // 512) * 512
265
+ _require(position <= len(snapshot), "archive", "truncated")
266
+ _require(not any(memoryview(snapshot)[end:position]), "archive", "invalid-padding")
267
+ if _BLOB.fullmatch(name):
268
+ digest = hashlib.sha256(memoryview(snapshot)[start:end]).hexdigest()
269
+ _require(digest == name.rsplit("/", 1)[1], "blob", "digest-mismatch")
270
+ raise OciArchiveError("archive", "missing-terminator")
271
+
272
+
273
+ def _object(value: object) -> dict[str, object]:
274
+ _require(isinstance(value, dict), "metadata")
275
+ return cast(dict[str, object], value)
276
+
277
+
278
+ def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]:
279
+ result: dict[str, object] = {}
280
+ for key, value in pairs:
281
+ _require(key not in result, "metadata", "duplicate-key")
282
+ result[key] = value
283
+ return result
284
+
285
+
286
+ def _invalid_constant(value: str) -> object:
287
+ raise OciArchiveError("metadata")
288
+
289
+
290
+ def _json(snapshot: bytes, entry: tuple[int, int]) -> dict[str, object]:
291
+ start, size = entry
292
+ _require(size <= offline_kit._MAX_MANIFEST_BYTES, "metadata", "limit")
293
+ return _object(
294
+ json.loads(
295
+ snapshot[start : start + size],
296
+ object_pairs_hook=_unique,
297
+ parse_constant=_invalid_constant,
298
+ )
299
+ )
300
+
301
+
302
+ def _schema(value: dict[str, object], media_type: str, stage: str) -> None:
303
+ _require(type(value.get("schemaVersion")) is int and value["schemaVersion"] == 2, stage)
304
+ _require(value.get("mediaType", media_type) == media_type, stage, "unsupported")
305
+ _require(not ({"artifactType", "subject"} & value.keys()), stage, "unsupported")
306
+
307
+
308
+ def _descriptor(
309
+ value: object,
310
+ entries: dict[str, tuple[int, int]],
311
+ media_types: set[str],
312
+ stage: str,
313
+ ) -> OciDescriptor:
314
+ descriptor = _object(value)
315
+ digest, size, media_type = (
316
+ descriptor.get("digest"),
317
+ descriptor.get("size"),
318
+ descriptor.get("mediaType"),
319
+ )
320
+ _require(isinstance(digest, str) and bool(_DIGEST.fullmatch(digest)), stage)
321
+ _require(type(size) is int and 0 <= size <= offline_kit._MAX_FILE_BYTES, stage, "limit")
322
+ _require(isinstance(media_type, str) and media_type in media_types, stage, "unsupported")
323
+ _require(not ({"urls", "data", "artifactType"} & descriptor.keys()), stage, "unsupported")
324
+ path = "blobs/sha256/" + cast(str, digest)[7:]
325
+ _require(path in entries and entries[path][1] == size, stage, "size-or-path-mismatch")
326
+ return OciDescriptor(
327
+ cast(str, digest), cast(int, size), cast(str, media_type), path, entries[path][0]
328
+ )
329
+
330
+
331
+ def _platform(value: dict[str, object], architecture: str) -> None:
332
+ _require(
333
+ value.get("os") == "linux" and value.get("architecture") == architecture,
334
+ "platform",
335
+ "mismatch",
336
+ )
337
+ variants = {"", "v8"} if architecture == "arm64" else {""}
338
+ _require(value.get("variant", "") in variants, "platform", "unsupported")
339
+ _require(
340
+ not value.get("os.features") and not value.get("os.version"), "platform", "unsupported"
341
+ )
342
+
343
+
344
+ def _revision(sources: tuple[object, ...], expected: str) -> None:
345
+ revisions: list[str] = []
346
+ for source in sources:
347
+ mapping = _object(source)
348
+ _require(all(isinstance(value, str) for value in mapping.values()), "revision")
349
+ if _REVISION in mapping:
350
+ revisions.append(cast(str, mapping[_REVISION]))
351
+ _require(
352
+ bool(revisions) and all(value == expected for value in revisions), "revision", "mismatch"
353
+ )