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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- 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
|
+
)
|