@mriekki/project-forge 4.6.0 → 4.8.0

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.
@@ -10,15 +10,19 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Any, Iterable
12
12
 
13
+ from .app_runtime import PRODUCTION_APP_DEPLOY_WRITE_PHRASE
13
14
  from .app_runtime import apply_app_deploy as _apply_runtime_deploy
15
+ from .app_runtime import deploy_write_phrase
14
16
  from .app_runtime import plan_app_deploy as _plan_runtime_deploy
15
17
  from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
16
18
  from .environment_isolation import environment_isolation_preflight
17
19
  from .errors import DeploymentKitError
18
20
  from .image_transfer import transfer_image
21
+ from .route_registry import ROUTE_WRITE_PHRASE, plan_route_registration
19
22
 
20
23
 
21
24
  ISOLATION_REVIEW_KEY = "isolationReview"
25
+ ROLLOUT_SCHEMA_VERSION = 1
22
26
 
23
27
 
24
28
  def app_isolation_check(
@@ -139,6 +143,100 @@ def plan_app_deploy(
139
143
  return plan
140
144
 
141
145
 
146
+ def _endpoint_states(route_plan: dict[str, Any] | None) -> dict[str, str]:
147
+ if route_plan is None:
148
+ return {
149
+ "beforeRuntime": "NO_ROUTE_REQUESTED",
150
+ "afterRuntimeBeforeRoute": "NO_ROUTE_REQUESTED",
151
+ "afterRoute": "NO_ROUTE_REQUESTED",
152
+ }
153
+ if route_plan["changeType"] == "create":
154
+ before = "NOT_ACTIVE_ROUTE_ABSENT"
155
+ after_runtime = "NOT_ACTIVE_ROUTE_PENDING"
156
+ else:
157
+ before = "ROUTE_PRESENT_RUNTIME_STATE_NOT_VALIDATED"
158
+ after_runtime = "ROUTE_PRESENT_ROUTE_CHANGE_PENDING"
159
+ return {
160
+ "beforeRuntime": before,
161
+ "afterRuntimeBeforeRoute": after_runtime,
162
+ "afterRoute": "ROUTE_REGISTERED_BROWSER_VERIFICATION_PENDING",
163
+ }
164
+
165
+
166
+ def plan_app_rollout(
167
+ target_name: str,
168
+ spec: dict[str, Any],
169
+ *,
170
+ production: bool = False,
171
+ database_file: str | None = None,
172
+ route_hostnames: Iterable[str] | None = None,
173
+ ) -> dict[str, Any]:
174
+ """Compose read-only runtime and route plans into one staged rollout preview."""
175
+
176
+ hosts = list(route_hostnames or [])
177
+ app_plan = plan_app_deploy(
178
+ target_name,
179
+ spec,
180
+ production=production,
181
+ database_file=database_file,
182
+ route_hostnames=hosts,
183
+ )
184
+ route_plan = None
185
+ if hosts:
186
+ route_plan = plan_route_registration(
187
+ target_name,
188
+ owner_repo=spec["ownerRepo"],
189
+ environment=spec["environment"],
190
+ app_slug=spec["appSlug"],
191
+ upstream_container=spec["containerName"],
192
+ upstream_port=spec["runtimePort"],
193
+ hostnames=hosts,
194
+ )
195
+
196
+ runtime_authorization = (
197
+ PRODUCTION_APP_DEPLOY_WRITE_PHRASE
198
+ if production or spec["environment"] == "production"
199
+ else deploy_write_phrase(app_plan)
200
+ )
201
+ stages = [
202
+ {
203
+ "stage": "runtime",
204
+ "action": "deploy-application-runtime",
205
+ "requiredAuthorization": runtime_authorization,
206
+ "mutated": False,
207
+ }
208
+ ]
209
+ if route_plan is not None:
210
+ stages.append(
211
+ {
212
+ "stage": "route",
213
+ "action": "register-application-route",
214
+ "requiredAuthorization": ROUTE_WRITE_PHRASE,
215
+ "mutated": False,
216
+ }
217
+ )
218
+
219
+ runtime_view = {key: value for key, value in app_plan.items() if key != "spec"}
220
+ return {
221
+ "schemaVersion": ROLLOUT_SCHEMA_VERSION,
222
+ "targetName": target_name,
223
+ "environment": spec["environment"],
224
+ "production": bool(production or spec["environment"] == "production"),
225
+ "image": spec["image"],
226
+ "runtimePlan": runtime_view,
227
+ "routePlan": route_plan,
228
+ "stages": stages,
229
+ "separateAuthorizationsRequired": route_plan is not None,
230
+ "endpointState": _endpoint_states(route_plan),
231
+ "postRouteVerification": (
232
+ ["application-health", "route-http", "workstation-browser"]
233
+ if route_plan is not None
234
+ else ["application-health"]
235
+ ),
236
+ "mutated": False,
237
+ }
238
+
239
+
142
240
  def revalidate_app_isolation(plan: dict[str, Any]) -> dict[str, Any]:
143
241
  """Recheck the reviewed application footprint immediately before mutation."""
144
242
 
@@ -186,9 +284,11 @@ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
186
284
 
187
285
  __all__ = [
188
286
  "ISOLATION_REVIEW_KEY",
287
+ "ROLLOUT_SCHEMA_VERSION",
189
288
  "app_isolation_check",
190
289
  "apply_app_deploy",
191
290
  "apply_app_deploy_from_workstation",
192
291
  "plan_app_deploy",
292
+ "plan_app_rollout",
193
293
  "revalidate_app_isolation",
194
294
  ]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
 
7
+ from .access_cli import main as access_main
7
8
  from .app_cli import main as app_main
8
9
  from .cli import main as core_main
9
10
  from .environment_cli import main as environment_main
@@ -16,6 +17,8 @@ from .topology_cli import main as topology_main
16
17
 
17
18
  def main(argv: list[str] | None = None) -> int:
18
19
  args = list(sys.argv[1:] if argv is None else argv)
20
+ if args and args[0] == "access":
21
+ return access_main(args[1:])
19
22
  if args and args[0] == "app":
20
23
  return app_main(args[1:])
21
24
  if args and args[0] == "environment":
@@ -31,6 +34,7 @@ def main(argv: list[str] | None = None) -> int:
31
34
  if args and args[0] == "topology":
32
35
  return topology_main(args[1:])
33
36
  if not args or args == ["--help"] or args == ["-h"]:
37
+ print("Command group: access Read-only strict SSH target-access diagnosis")
34
38
  print("Command group: app Remote immutable application runtime inventory/deployment")
35
39
  print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
36
40
  print("Command group: local Loopback-only workstation application runtime")
@@ -1,7 +1,8 @@
1
- """Read-only exact-image registry access verification for logical targets."""
1
+ """Read-only exact-image registry capability verification by execution context."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import re
6
7
  import shlex
7
8
  import subprocess
@@ -13,6 +14,17 @@ from .ssh import ssh_args, verify_access
13
14
 
14
15
 
15
16
  _IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
17
+ _REMOTE_DOES_NOT_PROVE = [
18
+ "workstation-registry-access",
19
+ "workstation-image-pull",
20
+ "actions-registry-transport",
21
+ "package-metadata-api-access",
22
+ ]
23
+ _WORKSTATION_DOES_NOT_PROVE = [
24
+ "remote-target-registry-access",
25
+ "actions-registry-transport",
26
+ "package-metadata-api-access",
27
+ ]
16
28
 
17
29
  _REMOTE_SCRIPT = r'''#!/usr/bin/env bash
18
30
  set -Eeuo pipefail
@@ -61,6 +73,14 @@ printf 'ACCESS\t%s\n' "$credential_state"
61
73
  '''
62
74
 
63
75
 
76
+ class RegistryCapabilityError(DeploymentKitError):
77
+ """Fail-closed registry capability error with safe structured evidence."""
78
+
79
+ def __init__(self, message: str, result: dict[str, Any]) -> None:
80
+ super().__init__(message)
81
+ self.result = result
82
+
83
+
64
84
  def normalize_registry_image(value: str) -> str:
65
85
  if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
66
86
  raise DeploymentKitError(
@@ -83,13 +103,55 @@ def _parse_access(output: str) -> str:
83
103
  return parts[1]
84
104
 
85
105
 
106
+ def _remote_failure_result(
107
+ *,
108
+ name: str,
109
+ image: str,
110
+ address: str,
111
+ returncode: int,
112
+ ) -> dict[str, Any]:
113
+ failure_codes = {
114
+ 42: "docker-cli-unavailable",
115
+ 43: "credential-config-unsafe",
116
+ 44: "manifest-access-failed",
117
+ }
118
+ return {
119
+ "status": "failed",
120
+ "executionLocation": "remote-target",
121
+ "targetName": name,
122
+ "verifiedAddress": address,
123
+ "capabilityAttempted": "registry-manifest-read",
124
+ "image": image,
125
+ "failureCode": failure_codes.get(returncode, "remote-command-failed"),
126
+ "doesNotProve": list(_REMOTE_DOES_NOT_PROVE),
127
+ "mutated": False,
128
+ }
129
+
130
+
131
+ def _workstation_failure_result(*, image: str, failure_code: str) -> dict[str, Any]:
132
+ return {
133
+ "status": "failed",
134
+ "executionLocation": "workstation",
135
+ "credentialContext": "workstation-docker-client",
136
+ "dockerConfigSource": "explicit-environment" if os.environ.get("DOCKER_CONFIG") else "default",
137
+ "capabilityAttempted": "registry-manifest-read",
138
+ "image": image,
139
+ "failureCode": failure_code,
140
+ "doesNotProve": list(_WORKSTATION_DOES_NOT_PROVE),
141
+ "mutated": False,
142
+ }
143
+
144
+
86
145
  def verify_registry_access(name: str, image: str) -> dict[str, Any]:
146
+ """Verify exact-image manifest access from the named remote target."""
147
+
87
148
  exact_image = normalize_registry_image(image)
88
149
  profile = load_profile(name)
89
150
  access = verify_access(profile)
151
+ address = access["address"]
90
152
  command = ssh_args(
91
153
  profile,
92
- access["address"],
154
+ address,
93
155
  command="bash -s -- " + shlex.quote(exact_image),
94
156
  )
95
157
  completed = subprocess.run(
@@ -99,16 +161,84 @@ def verify_registry_access(name: str, image: str) -> dict[str, Any]:
99
161
  capture_output=True,
100
162
  )
101
163
  stdout = completed.stdout.decode("utf-8", errors="replace")
102
- stderr = completed.stderr.decode("utf-8", errors="replace")
103
164
  if completed.returncode != 0:
104
- detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
105
- raise DeploymentKitError(f"Read-only registry access verification failed: {detail}")
165
+ result = _remote_failure_result(
166
+ name=name,
167
+ image=exact_image,
168
+ address=address,
169
+ returncode=completed.returncode,
170
+ )
171
+ raise RegistryCapabilityError(
172
+ f"Remote-target registry capability check failed ({result['failureCode']}).",
173
+ result,
174
+ )
106
175
 
107
176
  credential_state = _parse_access(stdout)
108
177
  return {
178
+ "status": "verified",
179
+ "executionLocation": "remote-target",
109
180
  "targetName": name,
181
+ "verifiedAddress": address,
182
+ "credentialContext": (
183
+ "target-docker-config" if credential_state == "protected" else "target-anonymous"
184
+ ),
185
+ "capabilityProved": "registry-manifest-read",
110
186
  "image": exact_image,
111
187
  "manifestAccess": "verified",
112
188
  "credentialConfig": credential_state,
189
+ "doesNotProve": list(_REMOTE_DOES_NOT_PROVE),
113
190
  "mutated": False,
114
191
  }
192
+
193
+
194
+ def verify_workstation_registry_access(image: str) -> dict[str, Any]:
195
+ """Verify exact-image manifest access from this workstation without pulling."""
196
+
197
+ exact_image = normalize_registry_image(image)
198
+ command = ["docker", "manifest", "inspect", exact_image]
199
+ try:
200
+ completed = subprocess.run(
201
+ command,
202
+ check=False,
203
+ capture_output=True,
204
+ )
205
+ except FileNotFoundError as error:
206
+ result = _workstation_failure_result(
207
+ image=exact_image,
208
+ failure_code="docker-cli-unavailable",
209
+ )
210
+ raise RegistryCapabilityError(
211
+ "Workstation registry capability check failed (docker-cli-unavailable).",
212
+ result,
213
+ ) from error
214
+
215
+ if completed.returncode != 0:
216
+ result = _workstation_failure_result(
217
+ image=exact_image,
218
+ failure_code="manifest-access-failed",
219
+ )
220
+ raise RegistryCapabilityError(
221
+ "Workstation registry capability check failed (manifest-access-failed).",
222
+ result,
223
+ )
224
+
225
+ return {
226
+ "status": "verified",
227
+ "executionLocation": "workstation",
228
+ "credentialContext": "workstation-docker-client",
229
+ "dockerConfigSource": "explicit-environment" if os.environ.get("DOCKER_CONFIG") else "default",
230
+ "capabilityProved": "registry-manifest-read",
231
+ "image": exact_image,
232
+ "manifestAccess": "verified",
233
+ "doesNotProve": list(_WORKSTATION_DOES_NOT_PROVE),
234
+ "mutated": False,
235
+ }
236
+
237
+
238
+ __all__ = [
239
+ "RegistryCapabilityError",
240
+ "build_remote_script",
241
+ "normalize_registry_image",
242
+ "verify_registry_access",
243
+ "verify_workstation_registry_access",
244
+ ]
@@ -1,4 +1,4 @@
1
- """CLI for read-only registry access verification."""
1
+ """CLI for read-only registry capability verification."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,7 +8,11 @@ import sys
8
8
  from typing import Any
9
9
 
10
10
  from .errors import DeploymentKitError
11
- from .registry_access import verify_registry_access
11
+ from .registry_access import (
12
+ RegistryCapabilityError,
13
+ verify_registry_access,
14
+ verify_workstation_registry_access,
15
+ )
12
16
 
13
17
 
14
18
  def _print(value: Any, *, as_json: bool) -> None:
@@ -28,11 +32,24 @@ def build_parser() -> argparse.ArgumentParser:
28
32
 
29
33
  verify = sub.add_parser(
30
34
  "verify",
31
- help="Verify exact-image registry manifest access without pulling or modifying target state",
35
+ help=(
36
+ "Verify exact-image registry manifest access on the named remote target without pulling "
37
+ "or modifying target state"
38
+ ),
32
39
  )
33
- verify.add_argument("name")
40
+ verify.add_argument("name", help="Named remote target profile")
34
41
  verify.add_argument("--image", required=True)
35
42
  verify.add_argument("--json", action="store_true")
43
+
44
+ verify_workstation = sub.add_parser(
45
+ "verify-workstation",
46
+ help=(
47
+ "Verify exact-image registry manifest access from this workstation without pulling "
48
+ "or modifying target state"
49
+ ),
50
+ )
51
+ verify_workstation.add_argument("--image", required=True)
52
+ verify_workstation.add_argument("--json", action="store_true")
36
53
  return parser
37
54
 
38
55
 
@@ -45,6 +62,18 @@ def main(argv: list[str] | None = None) -> int:
45
62
  as_json=args.json,
46
63
  )
47
64
  return 0
65
+ if args.command == "verify-workstation":
66
+ _print(
67
+ verify_workstation_registry_access(args.image),
68
+ as_json=args.json,
69
+ )
70
+ return 0
71
+ except RegistryCapabilityError as error:
72
+ if getattr(args, "json", False):
73
+ _print(error.result, as_json=True)
74
+ else:
75
+ print(f"ERROR: {error}", file=sys.stderr)
76
+ return 2
48
77
  except DeploymentKitError as error:
49
78
  print(f"ERROR: {error}", file=sys.stderr)
50
79
  return 2
@@ -180,27 +180,56 @@ def _posix_directory_chain(path: Path) -> list[Path]:
180
180
  current = current.parent
181
181
 
182
182
 
183
- def _check_posix_directories(path: Path) -> None:
183
+ def _posix_directory_issues(path: Path) -> list[str]:
184
+ issues: list[str] = []
185
+ current_uid = os.getuid()
184
186
  for directory in _posix_directory_chain(path):
185
- if directory.is_symlink() or not directory.is_dir():
186
- raise DeploymentKitError(f"SSH directory must be a real directory: {directory}")
187
- if directory.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
188
- raise DeploymentKitError(f"SSH directory permissions are too broad: {directory}")
187
+ if directory.is_symlink():
188
+ issues.append(f"symbolic link: {directory}")
189
+ continue
190
+ if not directory.exists():
191
+ continue
192
+ if not directory.is_dir():
193
+ issues.append(f"not a directory: {directory}")
194
+ continue
195
+ details = directory.stat()
196
+ if details.st_uid != current_uid:
197
+ issues.append(f"not owned by the current user: {directory}")
198
+ if details.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
199
+ issues.append(f"permissions are too broad: {directory}")
200
+ return issues
201
+
202
+
203
+ def _check_posix_directories(path: Path) -> None:
204
+ issues = _posix_directory_issues(path)
205
+ if issues:
206
+ raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
189
207
 
190
208
 
191
209
  def prepare_ssh_directory(path: Path) -> None:
192
- """Create one SSH-material directory when absent, then verify its safety before secrets are written."""
210
+ """Create missing owned SSH directories safely without silently repairing existing unsafe state."""
193
211
  _ssh_directory_for(path)
194
- if path.exists():
195
- if path.is_symlink() or not path.is_dir():
196
- raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
197
- else:
198
- path.mkdir(parents=True, mode=0o700)
199
- if os.name != "nt":
200
- path.chmod(0o700)
201
- _check_posix_directories(path / "placeholder")
202
- else:
212
+ if os.name == "nt":
213
+ if path.exists():
214
+ if path.is_symlink() or not path.is_dir():
215
+ raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
216
+ else:
217
+ path.mkdir(parents=True, mode=0o700)
203
218
  _check_windows_acl(path)
219
+ return
220
+
221
+ probe = path / "placeholder"
222
+ issues = _posix_directory_issues(probe)
223
+ if issues:
224
+ raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
225
+
226
+ for directory in reversed(_posix_directory_chain(probe)):
227
+ if directory.exists():
228
+ continue
229
+ directory.mkdir(mode=0o700)
230
+ directory.chmod(0o700)
231
+
232
+ _check_posix_directories(probe)
204
233
 
205
234
 
206
235
  def check_identity_security(identity: Path) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.6.0",
3
+ "version": "4.8.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,160 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const MANIFEST_RELATIVE = 'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json';
6
+ const BOOTSTRAP_MANIFEST = 'PROJECT_BOOTSTRAP.json';
7
+
8
+ function readJson(file) {
9
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
10
+ }
11
+
12
+ export function gitBlobSha(data) {
13
+ const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
14
+ const buffer = Buffer.from(raw.toString('utf8').replaceAll('\r\n', '\n'), 'utf8');
15
+ const header = Buffer.from(`blob ${buffer.length}\0`, 'utf8');
16
+ return crypto.createHash('sha1').update(header).update(buffer).digest('hex');
17
+ }
18
+
19
+ function safeRelative(value, label) {
20
+ if (typeof value !== 'string' || !value || path.isAbsolute(value)) {
21
+ throw new Error(`Unsafe ${label}: ${value}`);
22
+ }
23
+ const normalized = value.replaceAll('\\', '/');
24
+ if (normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
25
+ throw new Error(`Unsafe ${label}: ${value}`);
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ function safeConsumerPath(root, relative) {
31
+ const normalized = safeRelative(relative, 'Bootstrap migration destination');
32
+ const resolvedRoot = path.resolve(root);
33
+ const resolved = path.resolve(resolvedRoot, ...normalized.split('/'));
34
+ if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
35
+ throw new Error(`Unsafe Bootstrap migration destination: ${relative}`);
36
+ }
37
+ return resolved;
38
+ }
39
+
40
+ function loadManifest(packageData) {
41
+ const file = path.join(packageData.root, ...MANIFEST_RELATIVE.split('/'));
42
+ const manifest = readJson(file);
43
+ if (manifest.schemaVersion !== 1 || manifest.owner !== 'bootstrap') {
44
+ throw new Error('Invalid Bootstrap migration manifest ownership/schema');
45
+ }
46
+ if (manifest.source?.component !== 'bootstrap') {
47
+ throw new Error('Invalid Bootstrap migration logical source component');
48
+ }
49
+ if (!/^[0-9a-f]{40}$/.test(manifest.source?.revision ?? '')) {
50
+ throw new Error('Invalid Bootstrap migration source revision');
51
+ }
52
+ safeRelative(manifest.source?.artifact, 'Bootstrap migration source artifact');
53
+ if (!/^[0-9a-f]{40}$/.test(manifest.targetGitBlob ?? '')) {
54
+ throw new Error('Invalid Bootstrap migration target Git blob');
55
+ }
56
+ if (!Array.isArray(manifest.supportedPredecessorGitBlobs) || !manifest.supportedPredecessorGitBlobs.every((value) => /^[0-9a-f]{40}$/.test(value))) {
57
+ throw new Error('Invalid Bootstrap migration predecessor identities');
58
+ }
59
+ const assetRelative = safeRelative(manifest.asset, 'Bootstrap migration bundled asset');
60
+ const asset = path.join(packageData.root, ...assetRelative.split('/'));
61
+ const bytes = fs.readFileSync(asset);
62
+ if (gitBlobSha(bytes) !== manifest.targetGitBlob) {
63
+ throw new Error('Bundled Bootstrap migration asset identity does not match its manifest');
64
+ }
65
+ return { manifest, bytes };
66
+ }
67
+
68
+ export function planBootstrapMigrations(packageData, consumerRoot) {
69
+ const bootstrapManifest = path.join(consumerRoot, BOOTSTRAP_MANIFEST);
70
+ if (!fs.existsSync(bootstrapManifest)) return { rows: [], unsafe: [] };
71
+
72
+ const { manifest } = loadManifest(packageData);
73
+ const destination = safeConsumerPath(consumerRoot, manifest.destination);
74
+ if (!fs.existsSync(destination)) {
75
+ return {
76
+ rows: [],
77
+ unsafe: [{
78
+ owner: 'bootstrap',
79
+ state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
80
+ path: manifest.destination,
81
+ detail: 'Bootstrap-generated verifier is missing; refusing package mutation before a supported migration is established.'
82
+ }]
83
+ };
84
+ }
85
+
86
+ const stat = fs.lstatSync(destination);
87
+ if (!stat.isFile() || stat.isSymbolicLink()) {
88
+ return {
89
+ rows: [],
90
+ unsafe: [{
91
+ owner: 'bootstrap',
92
+ state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
93
+ path: manifest.destination,
94
+ detail: 'Bootstrap-generated verifier is not a regular file; refusing package mutation.'
95
+ }]
96
+ };
97
+ }
98
+ const realRoot = fs.realpathSync(consumerRoot);
99
+ const realDestination = fs.realpathSync(destination);
100
+ if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
101
+ return {
102
+ rows: [],
103
+ unsafe: [{
104
+ owner: 'bootstrap',
105
+ state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
106
+ path: manifest.destination,
107
+ detail: 'Bootstrap-generated verifier resolves outside the Project root; refusing package mutation.'
108
+ }]
109
+ };
110
+ }
111
+
112
+ const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
113
+ if (currentGitBlob === manifest.targetGitBlob) return { rows: [], unsafe: [] };
114
+ if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
115
+ return {
116
+ rows: [],
117
+ unsafe: [{
118
+ owner: 'bootstrap',
119
+ state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
120
+ path: manifest.destination,
121
+ detail: `Unknown/custom Bootstrap verifier identity ${currentGitBlob}; refusing to overwrite it.`
122
+ }]
123
+ };
124
+ }
125
+
126
+ return {
127
+ rows: [{
128
+ owner: 'bootstrap',
129
+ state: 'MIGRATE_BOOTSTRAP_VERIFIER',
130
+ path: manifest.destination,
131
+ fromGitBlob: currentGitBlob,
132
+ toGitBlob: manifest.targetGitBlob,
133
+ source: manifest.source
134
+ }],
135
+ unsafe: []
136
+ };
137
+ }
138
+
139
+ export function applyBootstrapMigrations(packageData, migrationPlan, consumerRoot) {
140
+ if (!migrationPlan?.rows?.length) return;
141
+ if (migrationPlan.unsafe?.length) throw new Error('Refusing unsafe Bootstrap migration plan');
142
+ const { manifest, bytes } = loadManifest(packageData);
143
+ for (const row of migrationPlan.rows) {
144
+ if (row.state !== 'MIGRATE_BOOTSTRAP_VERIFIER' || row.path !== manifest.destination) {
145
+ throw new Error(`Unsupported Bootstrap migration row: ${row.state ?? 'unknown'}`);
146
+ }
147
+ const destination = safeConsumerPath(consumerRoot, row.path);
148
+ if (!fs.existsSync(destination) || fs.lstatSync(destination).isSymbolicLink() || !fs.lstatSync(destination).isFile()) {
149
+ throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
150
+ }
151
+ const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
152
+ if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
153
+ throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
154
+ }
155
+ fs.writeFileSync(destination, bytes);
156
+ if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
157
+ throw new Error(`Bootstrap verifier migration verification failed: ${row.path}`);
158
+ }
159
+ }
160
+ }