jerryproxy 0.1.0__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 (93) hide show
  1. jerryproxy/__init__.py +5 -0
  2. jerryproxy/__main__.py +8 -0
  3. jerryproxy/backend/__init__.py +13 -0
  4. jerryproxy/backend/activation.py +1987 -0
  5. jerryproxy/backend/anchored.py +2041 -0
  6. jerryproxy/backend/archive.py +810 -0
  7. jerryproxy/backend/archive_preflight.py +724 -0
  8. jerryproxy/backend/catalog.py +290 -0
  9. jerryproxy/backend/download.py +235 -0
  10. jerryproxy/backend/durable.py +116 -0
  11. jerryproxy/backend/identity.py +290 -0
  12. jerryproxy/backend/installation.py +685 -0
  13. jerryproxy/backend/manager.py +1009 -0
  14. jerryproxy/backend/model.py +125 -0
  15. jerryproxy/backend/platform.py +71 -0
  16. jerryproxy/backend/recovery.py +96 -0
  17. jerryproxy/backend/registry.py +306 -0
  18. jerryproxy/backend/relay.py +156 -0
  19. jerryproxy/backend/removal.py +1814 -0
  20. jerryproxy/backend/state.py +379 -0
  21. jerryproxy/cli/__init__.py +59 -0
  22. jerryproxy/cli/_common.py +220 -0
  23. jerryproxy/cli/_completion.py +119 -0
  24. jerryproxy/cli/backend/__init__.py +85 -0
  25. jerryproxy/cli/backend/clean.py +129 -0
  26. jerryproxy/cli/backend/current.py +72 -0
  27. jerryproxy/cli/backend/install.py +194 -0
  28. jerryproxy/cli/backend/list.py +280 -0
  29. jerryproxy/cli/backend/uninstall.py +85 -0
  30. jerryproxy/cli/backend/use.py +46 -0
  31. jerryproxy/cli/backend/verify.py +64 -0
  32. jerryproxy/cli/backend/which.py +61 -0
  33. jerryproxy/cli/doctor.py +41 -0
  34. jerryproxy/cli/home.py +14 -0
  35. jerryproxy/cli/node/__init__.py +24 -0
  36. jerryproxy/cli/node/list.py +57 -0
  37. jerryproxy/cli/self_check.py +43 -0
  38. jerryproxy/cli/server.py +487 -0
  39. jerryproxy/cli/subscription/__init__.py +71 -0
  40. jerryproxy/cli/subscription/_common.py +252 -0
  41. jerryproxy/cli/subscription/add.py +76 -0
  42. jerryproxy/cli/subscription/list.py +48 -0
  43. jerryproxy/cli/subscription/refresh.py +32 -0
  44. jerryproxy/cli/subscription/remove.py +38 -0
  45. jerryproxy/cli/subscription/replace.py +63 -0
  46. jerryproxy/cli/subscription/show.py +26 -0
  47. jerryproxy/cli/subscription/validate.py +28 -0
  48. jerryproxy/config/__init__.py +5 -0
  49. jerryproxy/config/meta.py +7 -0
  50. jerryproxy/data/__init__.py +49 -0
  51. jerryproxy/data/mihomo.json +7710 -0
  52. jerryproxy/data/sing-box.json +20984 -0
  53. jerryproxy/data/v2ray.json +18944 -0
  54. jerryproxy/data/xray.json +15457 -0
  55. jerryproxy/errors.py +102 -0
  56. jerryproxy/home.py +206 -0
  57. jerryproxy/lock.py +195 -0
  58. jerryproxy/runtime/__init__.py +19 -0
  59. jerryproxy/runtime/guardian.py +335 -0
  60. jerryproxy/runtime/health.py +325 -0
  61. jerryproxy/runtime/interfaces.py +95 -0
  62. jerryproxy/runtime/mihomo.py +1791 -0
  63. jerryproxy/runtime/session.py +885 -0
  64. jerryproxy/selfcheck/__init__.py +36 -0
  65. jerryproxy/selfcheck/backend.py +69 -0
  66. jerryproxy/selfcheck/dependencies.py +83 -0
  67. jerryproxy/selfcheck/environment.py +101 -0
  68. jerryproxy/selfcheck/fixtures.py +76 -0
  69. jerryproxy/selfcheck/processes.py +538 -0
  70. jerryproxy/selfcheck/recovery.py +259 -0
  71. jerryproxy/selfcheck/relay.py +287 -0
  72. jerryproxy/selfcheck/resources.py +86 -0
  73. jerryproxy/selfcheck/result.py +128 -0
  74. jerryproxy/selfcheck/runner.py +173 -0
  75. jerryproxy/selfcheck/runtime.py +332 -0
  76. jerryproxy/selfcheck/subscription.py +252 -0
  77. jerryproxy/subscription/__init__.py +46 -0
  78. jerryproxy/subscription/audit.py +115 -0
  79. jerryproxy/subscription/interfaces.py +52 -0
  80. jerryproxy/subscription/manager.py +552 -0
  81. jerryproxy/subscription/model.py +115 -0
  82. jerryproxy/subscription/redaction.py +98 -0
  83. jerryproxy/subscription/storage.py +1227 -0
  84. jerryproxy/subscription/transport.py +812 -0
  85. jerryproxy/utils/__init__.py +1 -0
  86. jerryproxy/utils/fs.py +147 -0
  87. jerryproxy-0.1.0.dist-info/LICENSE +201 -0
  88. jerryproxy-0.1.0.dist-info/METADATA +741 -0
  89. jerryproxy-0.1.0.dist-info/RECORD +93 -0
  90. jerryproxy-0.1.0.dist-info/THIRD_PARTY_LICENSES.md +80 -0
  91. jerryproxy-0.1.0.dist-info/WHEEL +5 -0
  92. jerryproxy-0.1.0.dist-info/entry_points.txt +2 -0
  93. jerryproxy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1987 @@
1
+ """Strict activation journals and deterministic crash-recovery planning."""
2
+
3
+ import errno
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import stat
10
+ from copy import deepcopy
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path, PurePosixPath, PureWindowsPath
14
+
15
+ from ..errors import ArchiveError, DurabilityError, IntegrityError, UnsupportedBackendError
16
+ from ..home import is_path_alias
17
+ from ..utils.fs import MAXIMUM_JSON_BYTES
18
+ from .anchored import AnchoredDirectory
19
+ from .durable import durable_replace as _filesystem_durable_replace
20
+ from .durable import flush_descriptor, flush_directory
21
+ from .identity import capture_identity, identity_matches, validate_identity
22
+ from .registry import get_backend
23
+ from .removal import _secure_remove_tree
24
+ from .state import load_active_state, load_installed_manifest_evidence
25
+
26
+ _OPERATION_PATTERN = re.compile(r"^[0-9a-f]{32}$")
27
+ _JOURNAL_PATTERN = re.compile(r"^\.use-([0-9a-f]{32})\.json$")
28
+ _TEMPORARY_PATTERN = re.compile(r"^\.use-([0-9a-f]{32})\.json\.tmp-([0-9a-f]{32})$")
29
+ _LINK_CANDIDATE_PATTERN = re.compile(r"^\.(.+)\.use-([0-9a-f]{32})\.candidate$")
30
+ _MANIFEST_CANDIDATE_PATTERN = re.compile(r"^\.(.+)\.use-([0-9a-f]{32})\.candidate\.json$")
31
+ _DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$")
32
+ _TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)$")
33
+
34
+ PRECOMMIT_PHASES = frozenset(
35
+ (
36
+ "prepared",
37
+ "link-building",
38
+ "link-ready",
39
+ "manifest-building",
40
+ "candidates-ready",
41
+ "link-published",
42
+ "manifest-published",
43
+ )
44
+ )
45
+ PHASES = PRECOMMIT_PHASES | frozenset(("committed",))
46
+ RECOVERY_DIRECTIONS = frozenset(("rollback-previous", "rollback-absent", "rollforward-target"))
47
+ _PURPOSES = frozenset(("target", "recovery-previous", "recovery-target"))
48
+ _CANDIDATE_STATES = frozenset(("absent", "building", "ready", "published", "discarding"))
49
+ _COPY_CHUNK_SIZE = 1024 * 1024
50
+ _LOGICAL_KEYS = {
51
+ "version",
52
+ "executable",
53
+ "executable_size",
54
+ "executable_sha256",
55
+ "link_mode",
56
+ "manifest_payload",
57
+ }
58
+ _MANIFEST_KEYS = {"name", "version", "executable", "link", "activated_at", "link_mode"}
59
+ _CANDIDATE_KEYS = {
60
+ "path",
61
+ "purpose",
62
+ "state",
63
+ "identity",
64
+ "size",
65
+ "sha256",
66
+ "target",
67
+ "displaced_identity",
68
+ "displaced_purpose",
69
+ }
70
+ _TOP_LEVEL_KEYS = {
71
+ "kind",
72
+ "operation",
73
+ "phase",
74
+ "backend",
75
+ "link",
76
+ "manifest",
77
+ "previous",
78
+ "target",
79
+ "candidates",
80
+ "recovery",
81
+ }
82
+ _WINDOWS_SYMLINK_FALLBACK_WINERRORS = frozenset((1, 50, 1314))
83
+ _WINDOWS_SYMLINK_FALLBACK_ERRNOS = frozenset(
84
+ (
85
+ errno.EPERM,
86
+ getattr(errno, "ENOSYS", -1),
87
+ getattr(errno, "ENOTSUP", -1),
88
+ getattr(errno, "EOPNOTSUPP", -1),
89
+ )
90
+ )
91
+
92
+
93
+ def _windows_symlink_fallback_allowed(error):
94
+ if os.name != "nt":
95
+ return False
96
+ if isinstance(error, NotImplementedError):
97
+ return True
98
+ winerror = getattr(error, "winerror", None)
99
+ if winerror is not None:
100
+ return winerror in _WINDOWS_SYMLINK_FALLBACK_WINERRORS
101
+ return getattr(error, "errno", None) in _WINDOWS_SYMLINK_FALLBACK_ERRNOS
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ActivationClassification:
106
+ """Physical public and candidate classifications for one use journal."""
107
+
108
+ link: str
109
+ manifest: str
110
+ link_candidate: str
111
+ manifest_candidate: str
112
+ link_evidence: dict = None
113
+ manifest_evidence: dict = None
114
+ link_candidate_evidence: dict = None
115
+ manifest_candidate_evidence: dict = None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class ActivationRecoveryPlan:
120
+ """One durable, idempotent recovery transition selected by journal phase."""
121
+
122
+ journal: dict
123
+ direction: str
124
+ action: str
125
+ object_name: str = None
126
+ precondition: dict = None
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ActivationRecord:
131
+ """One validated authoritative record for global conflict preflight."""
132
+
133
+ kind: str
134
+ operation: str
135
+ journal_path: Path
136
+ read_paths: tuple
137
+ write_paths: tuple
138
+ state: dict
139
+ journal_identity: dict
140
+ temporaries: tuple = ()
141
+
142
+
143
+ def _fail(message):
144
+ raise IntegrityError("invalid activation journal: %s" % message)
145
+
146
+
147
+ def _exact_keys(value, keys, label):
148
+ if not isinstance(value, dict) or set(value) != keys:
149
+ _fail("invalid %s keys" % label)
150
+
151
+
152
+ def _bounded_string(value, label, maximum):
153
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum:
154
+ _fail("invalid %s" % label)
155
+ return value
156
+
157
+
158
+ def _relative_path(value, label):
159
+ value = _bounded_string(value, label, 1024)
160
+ windows = PureWindowsPath(value)
161
+ parts = value.split("/")
162
+ if (
163
+ "\\" in value
164
+ or value.startswith("/")
165
+ or not parts
166
+ or any(part in ("", ".", "..") for part in parts)
167
+ or PurePosixPath(value).is_absolute()
168
+ or windows.is_absolute()
169
+ or windows.drive
170
+ ):
171
+ _fail("invalid %s" % label)
172
+ return value
173
+
174
+
175
+ def _digest(value, label):
176
+ if not isinstance(value, str) or _DIGEST_PATTERN.fullmatch(value) is None:
177
+ _fail("invalid %s" % label)
178
+ return value
179
+
180
+
181
+ def _size(value, label):
182
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
183
+ _fail("invalid %s" % label)
184
+ return value
185
+
186
+
187
+ def _timestamp(value):
188
+ value = _bounded_string(value, "activation timestamp", 32)
189
+ if not value.isascii() or _TIMESTAMP_PATTERN.fullmatch(value) is None:
190
+ _fail("invalid activation timestamp")
191
+ try:
192
+ parsed = datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value)
193
+ except ValueError as error:
194
+ # datetime validates calendar fields after the exact lexical check.
195
+ raise IntegrityError("invalid activation journal: invalid activation timestamp") from error
196
+ if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
197
+ _fail("invalid activation timestamp")
198
+ return value
199
+
200
+
201
+ def _canonical_bytes(value):
202
+ return (json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n").encode("utf-8")
203
+
204
+
205
+ def _logical_state(paths, backend, link, value, allow_incomplete, label):
206
+ _exact_keys(value, _LOGICAL_KEYS, label)
207
+ spec = get_backend(backend)
208
+ version = _bounded_string(value["version"], "%s version" % label, 128)
209
+ try:
210
+ if spec.normalize_version(version) != version:
211
+ _fail("noncanonical %s version" % label)
212
+ except ValueError as error:
213
+ raise IntegrityError("invalid activation journal: invalid %s version" % label) from error
214
+ executable = _relative_path(value["executable"], "%s executable" % label)
215
+ executable_size = _size(value["executable_size"], "%s executable size" % label)
216
+ executable_sha256 = _digest(value["executable_sha256"], "%s executable digest" % label)
217
+ try:
218
+ installed, actual_size, actual_digest, unused_identity = load_installed_manifest_evidence(
219
+ paths,
220
+ paths.backends / backend / version / "manifest.json",
221
+ )
222
+ except (OSError, IntegrityError) as error:
223
+ # Immutable installation evidence can become unreadable or invalid after journal publication.
224
+ raise IntegrityError("invalid activation journal: unreadable immutable executable") from error
225
+ expected_executable = str(PurePosixPath(*installed.executable.relative_to(paths.root).parts))
226
+ if executable != expected_executable:
227
+ _fail("%s executable does not match immutable installation" % label)
228
+ if executable_size != actual_size or executable_sha256 != actual_digest:
229
+ _fail("%s executable evidence does not match immutable installation" % label)
230
+
231
+ mode = value["link_mode"]
232
+ payload = value["manifest_payload"]
233
+ if allow_incomplete and mode is None and payload is None:
234
+ return
235
+ if mode not in ("symlink", "copy"):
236
+ _fail("invalid %s link mode" % label)
237
+ _exact_keys(payload, _MANIFEST_KEYS, "%s manifest payload" % label)
238
+ if (
239
+ payload["name"] != backend
240
+ or payload["version"] != version
241
+ or payload["executable"] != executable
242
+ or payload["link"] != link
243
+ or payload["link_mode"] != mode
244
+ ):
245
+ _fail("%s manifest payload does not match its logical state" % label)
246
+ _timestamp(payload["activated_at"])
247
+
248
+
249
+ def _candidate(value, expected_path, recovery, phase, name, desired):
250
+ _exact_keys(value, _CANDIDATE_KEYS, "%s candidate" % name)
251
+ if _relative_path(value["path"], "%s candidate path" % name) != expected_path:
252
+ _fail("incorrect %s candidate path" % name)
253
+ purpose = value["purpose"]
254
+ state = value["state"]
255
+ if purpose not in _PURPOSES or state not in _CANDIDATE_STATES:
256
+ _fail("invalid %s candidate purpose or state" % name)
257
+ identity = value["identity"]
258
+ size = value["size"]
259
+ digest = value["sha256"]
260
+ target = value["target"]
261
+ displaced_identity = value["displaced_identity"]
262
+ displaced_purpose = value["displaced_purpose"]
263
+ if (displaced_identity is None) != (displaced_purpose is None):
264
+ _fail("incomplete %s displaced candidate evidence" % name)
265
+ if displaced_identity is not None:
266
+ if state != "ready" or displaced_purpose not in ("previous", "target"):
267
+ _fail("invalid %s displaced candidate evidence" % name)
268
+ validate_identity(displaced_identity)
269
+ if state == "absent":
270
+ if any(
271
+ item is not None
272
+ for item in (identity, size, digest, target, displaced_identity, displaced_purpose)
273
+ ):
274
+ _fail("absent %s candidate retains evidence" % name)
275
+ elif state == "building":
276
+ if any(item is not None for item in (size, digest, target, displaced_identity, displaced_purpose)):
277
+ _fail("building %s candidate has ready evidence" % name)
278
+ if identity is not None:
279
+ validate_identity(identity, expected_file_type="regular")
280
+ else:
281
+ if identity is None:
282
+ _fail("%s %s candidate has no identity" % (state, name))
283
+ validate_identity(identity)
284
+ file_type = identity["file_type"]
285
+ if file_type == "regular":
286
+ if state == "discarding" and size is None and digest is None:
287
+ pass
288
+ else:
289
+ _size(size, "%s candidate size" % name)
290
+ _digest(digest, "%s candidate digest" % name)
291
+ if target is not None:
292
+ _fail("regular %s candidate has a symlink target" % name)
293
+ elif file_type == "symlink":
294
+ if size is not None or digest is not None:
295
+ _fail("symlink %s candidate has regular-file evidence" % name)
296
+ _bounded_string(target, "%s candidate symlink target" % name, 1024)
297
+ else:
298
+ _fail("candidate cannot be a directory")
299
+ if state in ("ready", "published") or (state == "discarding" and (file_type == "symlink" or size is not None)):
300
+ if desired is None:
301
+ _fail("candidate purpose has no logical state")
302
+ if name == "link":
303
+ if desired["link_mode"] == "copy":
304
+ if (
305
+ file_type != "regular"
306
+ or size != desired["executable_size"]
307
+ or digest != desired["executable_sha256"]
308
+ ):
309
+ _fail("link candidate evidence does not match its purpose")
310
+ else:
311
+ expected_target = os.path.relpath(desired["executable"], "bin")
312
+ if file_type != "symlink" or target != expected_target:
313
+ _fail("link candidate evidence does not match its purpose")
314
+ else:
315
+ payload = _canonical_bytes(desired["manifest_payload"])
316
+ if file_type != "regular" or size != len(payload) or digest != hashlib.sha256(payload).hexdigest():
317
+ _fail("manifest candidate evidence does not match its purpose")
318
+
319
+ if recovery is None:
320
+ if purpose != "target":
321
+ _fail("normal candidate has a recovery purpose")
322
+ if state == "building" and phase not in ("link-building", "manifest-building"):
323
+ _fail("building candidate is illegal in this normal phase")
324
+ else:
325
+ expected_purpose = {
326
+ "rollback-previous": "recovery-previous",
327
+ "rollforward-target": "recovery-target",
328
+ }.get(recovery["direction"])
329
+ if state != "discarding" and purpose != "target" and purpose != expected_purpose:
330
+ _fail("candidate purpose disagrees with recovery direction")
331
+
332
+
333
+ def _validate_normal_phase(value):
334
+ phase = value["phase"]
335
+ target = value["target"]
336
+ link_state = value["candidates"]["link"]["state"]
337
+ manifest_state = value["candidates"]["manifest"]["state"]
338
+ if phase == "prepared":
339
+ expected = ("absent", "absent")
340
+ if target["link_mode"] is not None or target["manifest_payload"] is not None:
341
+ _fail("prepared target must be incomplete")
342
+ else:
343
+ if target["link_mode"] is None or target["manifest_payload"] is None:
344
+ _fail("target must be complete after prepared")
345
+ expected = {
346
+ "link-building": ("building", "absent"),
347
+ "link-ready": ("ready", "absent"),
348
+ "manifest-building": ("ready", "building"),
349
+ "candidates-ready": ("ready", "ready"),
350
+ "link-published": ("published", "ready"),
351
+ "manifest-published": ("published", "published"),
352
+ }.get(phase)
353
+ if phase == "committed":
354
+ if link_state not in ("published", "discarding", "absent") or manifest_state not in (
355
+ "published",
356
+ "discarding",
357
+ "absent",
358
+ ):
359
+ _fail("invalid committed candidate state")
360
+ return
361
+ if (link_state, manifest_state) != expected:
362
+ _fail("candidate states disagree with normal phase")
363
+
364
+
365
+ def _validate_recovery_phase(value):
366
+ phase = value["phase"]
367
+ expected = {
368
+ "prepared": ("absent", "absent"),
369
+ "link-building": ("building", "absent"),
370
+ "link-ready": ("ready", "absent"),
371
+ "manifest-building": ("ready", "building"),
372
+ "candidates-ready": ("ready", "ready"),
373
+ "link-published": ("published", "ready"),
374
+ "manifest-published": ("published", "published"),
375
+ "committed": ("published", "published"),
376
+ }[phase]
377
+ for name, original_state in zip(("link", "manifest"), expected):
378
+ candidate = value["candidates"][name]
379
+ if candidate["purpose"] != "target":
380
+ continue
381
+ allowed = {original_state, "discarding", "absent"}
382
+ if candidate["state"] not in allowed:
383
+ _fail("candidate states disagree with original phase")
384
+
385
+
386
+ def recovery_direction(journal):
387
+ # type: (dict) -> str
388
+ """Return the only recovery direction authorized by the commit phase."""
389
+
390
+ if journal["phase"] == "committed":
391
+ return "rollforward-target"
392
+ return "rollback-absent" if journal["previous"] is None else "rollback-previous"
393
+
394
+
395
+ def _load_use_journal(paths, journal, platform_info, expected_identity=None):
396
+ journal = Path(journal)
397
+ match = _JOURNAL_PATTERN.fullmatch(journal.name)
398
+ if match is None or journal.parent.absolute() != paths.runtimes.absolute():
399
+ _fail("invalid authoritative filename")
400
+ if is_path_alias(journal):
401
+ _fail("authoritative journal is aliased")
402
+ try:
403
+ status = journal.lstat()
404
+ if not stat.S_ISREG(status.st_mode):
405
+ _fail("authoritative journal is not regular")
406
+ if os.name == "posix" and stat.S_IMODE(status.st_mode) != 0o600:
407
+ _fail("unsafe authoritative journal permissions")
408
+ with AnchoredDirectory(paths.runtimes) as runtimes:
409
+ value, journal_identity = runtimes.read_json(
410
+ (journal.name,),
411
+ expected_identity=expected_identity,
412
+ )
413
+ except (ArchiveError, OSError, ValueError) as error:
414
+ # Bounded strict JSON and filesystem inspection define the journal input boundary.
415
+ raise IntegrityError("invalid activation journal: unreadable authoritative record") from error
416
+ _exact_keys(value, _TOP_LEVEL_KEYS, "top-level")
417
+ operation = value["operation"]
418
+ if value["kind"] != "use" or not isinstance(operation, str) or _OPERATION_PATTERN.fullmatch(operation) is None:
419
+ _fail("invalid kind or operation")
420
+ if operation != match.group(1):
421
+ _fail("operation does not match filename")
422
+ phase = value["phase"]
423
+ if phase not in PHASES:
424
+ _fail("invalid phase")
425
+ backend = _bounded_string(value["backend"], "backend", 64)
426
+ try:
427
+ spec = get_backend(backend)
428
+ except UnsupportedBackendError as error:
429
+ raise IntegrityError("invalid activation journal: unsupported backend") from error
430
+ if spec.name != backend:
431
+ _fail("noncanonical backend")
432
+ link = "bin/%s" % spec.executable_filename(platform_info)
433
+ manifest = "active/%s.json" % backend
434
+ if value["link"] != link or value["manifest"] != manifest:
435
+ _fail("incorrect public paths")
436
+ expected_link_candidate = "bin/.%s.use-%s.candidate" % (Path(link).name, operation)
437
+ expected_manifest_candidate = "active/.%s.use-%s.candidate.json" % (backend, operation)
438
+ _exact_keys(value["candidates"], {"link", "manifest"}, "candidates")
439
+
440
+ recovery = value["recovery"]
441
+ if recovery is not None:
442
+ _exact_keys(recovery, {"direction"}, "recovery")
443
+ if recovery["direction"] not in RECOVERY_DIRECTIONS:
444
+ _fail("invalid recovery direction")
445
+ _logical_state(paths, backend, link, value["target"], phase == "prepared", "target")
446
+ if value["previous"] is not None:
447
+ _logical_state(paths, backend, link, value["previous"], False, "previous")
448
+ for name, expected in (("link", expected_link_candidate), ("manifest", expected_manifest_candidate)):
449
+ candidate = value["candidates"][name]
450
+ desired = value["target"]
451
+ if candidate.get("purpose") == "recovery-previous":
452
+ desired = value["previous"]
453
+ _candidate(candidate, expected, recovery, phase, name, desired)
454
+ if candidate["displaced_purpose"] == "previous" and value["previous"] is None:
455
+ _fail("%s displaced candidate has no previous logical state" % name)
456
+ displaced_logical = (
457
+ value["previous"] if candidate["displaced_purpose"] == "previous" else value["target"]
458
+ )
459
+ if candidate["displaced_identity"] is not None:
460
+ expected_type = "regular"
461
+ if name == "link" and displaced_logical["link_mode"] == "symlink":
462
+ expected_type = "symlink"
463
+ if candidate["displaced_identity"]["file_type"] != expected_type:
464
+ _fail("%s displaced candidate type disagrees with its logical state" % name)
465
+ if recovery is None:
466
+ _validate_normal_phase(value)
467
+ elif recovery["direction"] != recovery_direction(value):
468
+ _fail("persisted recovery direction disagrees with commit phase")
469
+ else:
470
+ _validate_recovery_phase(value)
471
+ return value, journal_identity
472
+
473
+
474
+ def load_use_journal(paths, journal, platform_info):
475
+ # type: (JerryProxyPaths, Path, PlatformInfo) -> dict
476
+ """Strictly load and validate one authoritative activation journal."""
477
+
478
+ value, unused_identity = _load_use_journal(paths, journal, platform_info)
479
+ return value
480
+
481
+
482
+ def _record_paths(paths, value, temporaries=()):
483
+ read_paths = {value["link"], value["manifest"]}
484
+ write_paths = {
485
+ "runtimes/.use-%s.json" % value["operation"],
486
+ value["link"],
487
+ value["manifest"],
488
+ value["candidates"]["link"]["path"],
489
+ value["candidates"]["manifest"]["path"],
490
+ }
491
+ for logical in (value["previous"], value["target"]):
492
+ if logical is None:
493
+ continue
494
+ read_paths.add(logical["executable"])
495
+ read_paths.add("backends/%s/%s/manifest.json" % (value["backend"], logical["version"]))
496
+ write_paths.update(str(PurePosixPath(*path.relative_to(paths.root).parts)) for path, unused_identity in temporaries)
497
+ return tuple(sorted(read_paths)), tuple(sorted(write_paths))
498
+
499
+
500
+ def _validate_writer_temporary(path):
501
+ if is_path_alias(path):
502
+ raise IntegrityError("invalid activation recovery writer temporary alias: %s" % path)
503
+ try:
504
+ status = path.lstat()
505
+ except OSError as error:
506
+ # Enumerated writer evidence must remain inspectable through preflight.
507
+ raise IntegrityError("unable to inspect activation recovery writer temporary: %s" % path) from error
508
+ if not stat.S_ISREG(status.st_mode) or status.st_size > MAXIMUM_JSON_BYTES:
509
+ raise IntegrityError("invalid activation recovery writer temporary: %s" % path)
510
+ if os.name == "posix" and stat.S_IMODE(status.st_mode) != 0o600:
511
+ raise IntegrityError("unsafe activation recovery writer temporary permissions: %s" % path)
512
+ return capture_identity(path)
513
+
514
+
515
+ def _scan_candidate_namespace(area, pattern):
516
+ owned = {}
517
+ try:
518
+ entries = sorted(area.iterdir(), key=lambda item: item.name)
519
+ except OSError as error:
520
+ # Candidate namespaces must be enumerated completely before recovery mutates state.
521
+ raise IntegrityError("unable to enumerate activation recovery candidates: %s" % area) from error
522
+ for entry in entries:
523
+ if ".use-" not in entry.name:
524
+ continue
525
+ match = pattern.fullmatch(entry.name)
526
+ if match is None:
527
+ raise IntegrityError("invalid activation recovery candidate entry: %s" % entry)
528
+ owned.setdefault(match.group(2), []).append(entry)
529
+ return owned
530
+
531
+
532
+ def _scan_use_recovery(paths, platform_info):
533
+ journals = {}
534
+ temporaries = {}
535
+ try:
536
+ entries = sorted(paths.runtimes.iterdir(), key=lambda item: item.name)
537
+ except OSError as error:
538
+ # Runtime enumeration must complete before the coordinator permits mutation.
539
+ raise IntegrityError("unable to enumerate activation journals") from error
540
+ for entry in entries:
541
+ if not entry.name.startswith(".use-"):
542
+ continue
543
+ journal_match = _JOURNAL_PATTERN.fullmatch(entry.name)
544
+ temporary_match = _TEMPORARY_PATTERN.fullmatch(entry.name)
545
+ if journal_match is not None:
546
+ operation = journal_match.group(1)
547
+ if operation in journals:
548
+ raise IntegrityError("duplicate activation recovery operation: %s" % operation)
549
+ journals[operation] = entry
550
+ elif temporary_match is not None:
551
+ operation = temporary_match.group(1)
552
+ identity = _validate_writer_temporary(entry)
553
+ temporaries.setdefault(operation, []).append((entry, identity))
554
+ else:
555
+ raise IntegrityError("invalid activation recovery runtime entry: %s" % entry)
556
+
557
+ link_candidates = _scan_candidate_namespace(paths.bin, _LINK_CANDIDATE_PATTERN)
558
+ manifest_candidates = _scan_candidate_namespace(paths.active, _MANIFEST_CANDIDATE_PATTERN)
559
+ records = []
560
+ for operation in sorted(journals):
561
+ journal = journals[operation]
562
+ state, journal_identity = _load_use_journal(paths, journal, platform_info)
563
+ if not identity_matches(journal, journal_identity):
564
+ raise IntegrityError("activation recovery journal changed during preflight: %s" % journal)
565
+ expected = {
566
+ paths.root / state["candidates"]["link"]["path"],
567
+ paths.root / state["candidates"]["manifest"]["path"],
568
+ }
569
+ actual = set(link_candidates.get(operation, ())) | set(manifest_candidates.get(operation, ()))
570
+ unexpected = actual.difference(expected)
571
+ if unexpected:
572
+ raise IntegrityError("unexpected activation recovery candidate: %s" % sorted(unexpected)[0])
573
+ operation_temporaries = tuple(sorted(temporaries.get(operation, ()), key=lambda item: item[0]))
574
+ read_paths, write_paths = _record_paths(paths, state, operation_temporaries)
575
+ records.append(
576
+ ActivationRecord(
577
+ kind="use",
578
+ operation=state["operation"],
579
+ journal_path=journal,
580
+ read_paths=read_paths,
581
+ write_paths=write_paths,
582
+ state=state,
583
+ journal_identity=journal_identity,
584
+ temporaries=operation_temporaries,
585
+ )
586
+ )
587
+ owned_operations = set(link_candidates) | set(manifest_candidates)
588
+ missing_authority = owned_operations.difference(journals)
589
+ if missing_authority:
590
+ operation = sorted(missing_authority)[0]
591
+ raise IntegrityError("activation recovery candidate exists without authority: %s" % operation)
592
+ orphan_temporaries = []
593
+ for operation, items in temporaries.items():
594
+ if operation in journals:
595
+ continue
596
+ if operation in owned_operations:
597
+ raise IntegrityError("activation recovery writer temporary has owned candidate without authority")
598
+ orphan_temporaries.extend(items)
599
+ return tuple(records), tuple(sorted(orphan_temporaries, key=lambda item: item[0]))
600
+
601
+
602
+ def discover_use_journals(paths, platform_info):
603
+ # type: (JerryProxyPaths, PlatformInfo) -> tuple
604
+ """Discover and strictly parse authoritative use records without mutation."""
605
+
606
+ records, unused_orphans = _scan_use_recovery(paths, platform_info)
607
+ return records
608
+
609
+
610
+ def _matches_link(path, state, evidence):
611
+ if state is None or evidence is None:
612
+ return False
613
+ if state["link_mode"] == "symlink":
614
+ expected = os.path.relpath(
615
+ str(path.parents[1] / state["executable"]),
616
+ str(path.parent),
617
+ )
618
+ return evidence == ("symlink", expected)
619
+ return evidence == (
620
+ "regular",
621
+ state["executable_size"],
622
+ state["executable_sha256"],
623
+ )
624
+
625
+
626
+ def _read_link_evidence(path):
627
+ status = path.lstat()
628
+ if stat.S_ISLNK(status.st_mode):
629
+ return "symlink", os.readlink(str(path))
630
+ if not stat.S_ISREG(status.st_mode):
631
+ return None
632
+ with AnchoredDirectory(path.parent) as parent:
633
+ size, digest, unused_identity = parent.file_evidence((path.name,))
634
+ return "regular", size, digest
635
+
636
+
637
+ def classify_public_link(paths, journal):
638
+ # type: (JerryProxyPaths, dict) -> str
639
+ """Classify the physical active command as P, T, B, M, or U."""
640
+
641
+ path = paths.root / journal["link"]
642
+ if not os.path.lexists(str(path)):
643
+ return "M"
644
+ try:
645
+ evidence = _read_link_evidence(path)
646
+ except (ArchiveError, OSError, IntegrityError):
647
+ # Inaccessible or racing public objects are unknown, never absent.
648
+ return "U"
649
+ previous = _matches_link(path, journal["previous"], evidence)
650
+ target = _matches_link(path, journal["target"], evidence)
651
+ if previous and target:
652
+ return "B"
653
+ if previous:
654
+ return "P"
655
+ if target:
656
+ return "T"
657
+ return "U"
658
+
659
+
660
+ def _read_manifest_classification(path):
661
+ if is_path_alias(path):
662
+ return None
663
+ try:
664
+ with AnchoredDirectory(Path(path).parent) as parent:
665
+ value, unused_identity = parent.read_json((Path(path).name,))
666
+ return value
667
+ except (ArchiveError, OSError, ValueError, IntegrityError):
668
+ # Malformed or racing public state is unknown, never absent.
669
+ return None
670
+
671
+
672
+ def classify_public_manifest(paths, journal):
673
+ # type: (JerryProxyPaths, dict) -> str
674
+ """Classify the physical active manifest as P, T, M, or U."""
675
+
676
+ path = paths.root / journal["manifest"]
677
+ if not os.path.lexists(str(path)):
678
+ return "M"
679
+ value = _read_manifest_classification(path)
680
+ if value is None:
681
+ return "U"
682
+ if journal["previous"] is not None and value == journal["previous"]["manifest_payload"]:
683
+ return "P"
684
+ if value == journal["target"]["manifest_payload"]:
685
+ return "T"
686
+ return "U"
687
+
688
+
689
+ def classify_candidate(paths, journal, name):
690
+ # type: (JerryProxyPaths, dict, str) -> str
691
+ """Classify one activation candidate without changing or pinning it."""
692
+
693
+ candidate = journal["candidates"][name]
694
+ path = paths.root / candidate["path"]
695
+ if not os.path.lexists(str(path)):
696
+ if candidate["state"] == "building" and candidate["identity"] is not None:
697
+ return "unknown"
698
+ if candidate["state"] in ("ready", "published"):
699
+ public = paths.root / journal[name if name == "link" else "manifest"]
700
+ physical = (
701
+ classify_public_link(paths, journal) if name == "link" else classify_public_manifest(paths, journal)
702
+ )
703
+ if candidate["state"] == "published" and physical == "M":
704
+ return "missing"
705
+ if candidate["identity"] is None or not identity_matches(public, candidate["identity"]):
706
+ return "unknown"
707
+ desired = "P" if candidate["purpose"] == "recovery-previous" else "T"
708
+ if physical != desired and not (name == "link" and physical == "B"):
709
+ return "unknown"
710
+ return "recorded-owned"
711
+ return "missing"
712
+ identity = candidate["identity"]
713
+ if identity is not None:
714
+ if not identity_matches(path, identity):
715
+ displaced_identity = candidate["displaced_identity"]
716
+ public = paths.root / journal[name if name == "link" else "manifest"]
717
+ physical = (
718
+ classify_public_link(paths, journal) if name == "link" else classify_public_manifest(paths, journal)
719
+ )
720
+ desired_physical = "P" if candidate["purpose"] == "recovery-previous" else "T"
721
+ if (
722
+ candidate["state"] == "ready"
723
+ and displaced_identity is not None
724
+ and identity_matches(path, displaced_identity)
725
+ and identity_matches(public, identity)
726
+ and (physical == desired_physical or (name == "link" and physical == "B"))
727
+ ):
728
+ return "displaced-public"
729
+ return "unknown"
730
+ if candidate["state"] == "published":
731
+ return "unknown"
732
+ if candidate["state"] in ("ready", "discarding"):
733
+ try:
734
+ status = path.lstat()
735
+ if identity["file_type"] == "regular":
736
+ with AnchoredDirectory(_candidate_area(paths, name)) as parent:
737
+ size, digest, opened_identity = parent.file_evidence(
738
+ (path.name,),
739
+ expected_identity=identity,
740
+ )
741
+ if opened_identity != identity:
742
+ return "unknown"
743
+ if candidate["size"] is not None and (size != candidate["size"] or digest != candidate["sha256"]):
744
+ return "unknown"
745
+ elif not stat.S_ISLNK(status.st_mode) or os.readlink(str(path)) != candidate["target"]:
746
+ return "unknown"
747
+ except (ArchiveError, OSError, IntegrityError):
748
+ # Candidate evidence may become unreadable or change during concurrent inspection.
749
+ return "unknown"
750
+ return "recorded-owned"
751
+ try:
752
+ status = path.lstat()
753
+ if stat.S_ISREG(status.st_mode) and status.st_size == 0 and candidate["state"] == "building":
754
+ return "exact-unrecorded-empty-regular"
755
+ desired = journal["target"]
756
+ if candidate["purpose"] == "recovery-previous":
757
+ desired = journal["previous"]
758
+ if stat.S_ISREG(status.st_mode) and desired is not None:
759
+ with AnchoredDirectory(_candidate_area(paths, name)) as parent:
760
+ size, digest, unused_identity = parent.file_evidence((path.name,))
761
+ if name == "link" and desired["link_mode"] != "symlink":
762
+ if size == desired["executable_size"] and digest == desired["executable_sha256"]:
763
+ return "exact-unrecorded-purpose-object"
764
+ elif name == "manifest":
765
+ payload = _canonical_bytes(desired["manifest_payload"])
766
+ if size == len(payload) and digest == hashlib.sha256(payload).hexdigest():
767
+ return "exact-unrecorded-purpose-object"
768
+ if (
769
+ name == "link"
770
+ and candidate["state"] in ("absent", "building")
771
+ and desired is not None
772
+ and stat.S_ISLNK(status.st_mode)
773
+ and os.readlink(str(path)) == os.path.relpath(str(paths.root / desired["executable"]), str(path.parent))
774
+ ):
775
+ return "exact-unrecorded-purpose-object"
776
+ except (ArchiveError, OSError, IntegrityError):
777
+ # An unrecorded candidate may disappear or become unreadable while it is classified.
778
+ return "unknown"
779
+ return "unknown"
780
+
781
+
782
+ def _classify_with_stable_evidence(path, classify):
783
+ if not os.path.lexists(str(path)):
784
+ result = classify()
785
+ if result == "M" and not os.path.lexists(str(path)):
786
+ return result, {"classification": result, "identity": None}
787
+ return "U", None
788
+ try:
789
+ identity = capture_identity(path)
790
+ result = classify()
791
+ if not identity_matches(path, identity):
792
+ return "U", None
793
+ except (OSError, IntegrityError):
794
+ # Racing or unsupported filesystem objects are unknown physical state.
795
+ return "U", None
796
+ return result, {"classification": result, "identity": identity}
797
+
798
+
799
+ def _classify_candidate_with_stable_evidence(paths, journal, name):
800
+ path = paths.root / journal["candidates"][name]["path"]
801
+ if not os.path.lexists(str(path)):
802
+ result = classify_candidate(paths, journal, name)
803
+ if result != "unknown" and not os.path.lexists(str(path)):
804
+ return result, {"classification": result, "identity": None}
805
+ return "unknown", None
806
+ try:
807
+ identity = capture_identity(path)
808
+ result = classify_candidate(paths, journal, name)
809
+ if not identity_matches(path, identity):
810
+ return "unknown", None
811
+ except (OSError, IntegrityError):
812
+ # Racing or unsupported candidate objects never gain recovery authority.
813
+ return "unknown", None
814
+ return result, {"classification": result, "identity": identity}
815
+
816
+
817
+ def classify_activation(paths, journal):
818
+ # type: (JerryProxyPaths, dict) -> ActivationClassification
819
+ """Return the complete non-mutating physical classification."""
820
+
821
+ link, link_evidence = _classify_with_stable_evidence(
822
+ paths.root / journal["link"],
823
+ lambda: classify_public_link(paths, journal),
824
+ )
825
+ manifest, manifest_evidence = _classify_with_stable_evidence(
826
+ paths.root / journal["manifest"],
827
+ lambda: classify_public_manifest(paths, journal),
828
+ )
829
+ link_candidate, link_candidate_evidence = _classify_candidate_with_stable_evidence(paths, journal, "link")
830
+ manifest_candidate, manifest_candidate_evidence = _classify_candidate_with_stable_evidence(
831
+ paths, journal, "manifest"
832
+ )
833
+ return ActivationClassification(
834
+ link=link,
835
+ manifest=manifest,
836
+ link_candidate=link_candidate,
837
+ manifest_candidate=manifest_candidate,
838
+ link_evidence=link_evidence,
839
+ manifest_evidence=manifest_evidence,
840
+ link_candidate_evidence=link_candidate_evidence,
841
+ manifest_candidate_evidence=manifest_candidate_evidence,
842
+ )
843
+
844
+
845
+ def _copy_journal(journal):
846
+ return json.loads(json.dumps(journal, allow_nan=False))
847
+
848
+
849
+ def _adopt_displaced_candidate(journal, name):
850
+ candidate = journal["candidates"][name]
851
+ displaced_purpose = candidate["displaced_purpose"]
852
+ logical = journal[displaced_purpose]
853
+ candidate["purpose"] = "recovery-previous" if displaced_purpose == "previous" else "recovery-target"
854
+ candidate["state"] = "discarding"
855
+ candidate["identity"] = candidate["displaced_identity"]
856
+ candidate["size"] = None
857
+ candidate["sha256"] = None
858
+ candidate["target"] = None
859
+ if name == "link" and logical["link_mode"] == "symlink":
860
+ candidate["target"] = os.path.relpath(logical["executable"], "bin")
861
+ candidate["displaced_identity"] = None
862
+ candidate["displaced_purpose"] = None
863
+
864
+
865
+ def plan_activation_recovery(journal, classification):
866
+ # type: (dict, ActivationClassification) -> ActivationRecoveryPlan
867
+ """Plan one restart-safe transition; callers durably publish returned journals."""
868
+
869
+ if "U" in (classification.link, classification.manifest) or "unknown" in (
870
+ classification.link_candidate,
871
+ classification.manifest_candidate,
872
+ ):
873
+ raise IntegrityError("activation recovery found unknown physical state")
874
+ updated = _copy_journal(journal)
875
+ direction = recovery_direction(updated)
876
+ if updated["recovery"] is None:
877
+ updated["recovery"] = {"direction": direction}
878
+ return ActivationRecoveryPlan(updated, direction, "persist-direction")
879
+ if updated["recovery"] != {"direction": direction}:
880
+ raise IntegrityError("activation recovery direction changed after publication")
881
+
882
+ expected_purpose = {
883
+ "rollback-previous": "recovery-previous",
884
+ "rollforward-target": "recovery-target",
885
+ }.get(direction)
886
+ candidate_classifications = (
887
+ ("link", classification.link_candidate),
888
+ ("manifest", classification.manifest_candidate),
889
+ )
890
+ for name, candidate_classification in candidate_classifications:
891
+ candidate = updated["candidates"][name]
892
+ if candidate["state"] != "discarding":
893
+ continue
894
+ if candidate_classification != "missing":
895
+ return ActivationRecoveryPlan(updated, direction, "delete-candidate", name)
896
+ _clear_candidate(candidate)
897
+ candidate["purpose"] = expected_purpose or "target"
898
+ return ActivationRecoveryPlan(updated, direction, "persist-candidate-absent", name)
899
+
900
+ desired_link = "M" if direction == "rollback-absent" else ("P" if direction == "rollback-previous" else "T")
901
+ desired_manifest = desired_link
902
+ for name, candidate_classification in candidate_classifications:
903
+ candidate = updated["candidates"][name]
904
+ if candidate["purpose"] != "target":
905
+ continue
906
+ if candidate_classification == "displaced-public":
907
+ _adopt_displaced_candidate(updated, name)
908
+ return ActivationRecoveryPlan(updated, direction, "persist-displaced-candidate", name)
909
+ if candidate_classification in (
910
+ "exact-unrecorded-purpose-object",
911
+ "exact-unrecorded-empty-regular",
912
+ ):
913
+ evidence = (
914
+ classification.link_candidate_evidence if name == "link" else classification.manifest_candidate_evidence
915
+ )
916
+ return ActivationRecoveryPlan(
917
+ updated,
918
+ direction,
919
+ "pin-unrecorded-candidate",
920
+ name,
921
+ evidence,
922
+ )
923
+ if candidate["state"] != "absent":
924
+ if (
925
+ candidate["state"] == "building"
926
+ and candidate_classification == "missing"
927
+ and candidate["identity"] is None
928
+ ):
929
+ _clear_candidate(candidate)
930
+ return ActivationRecoveryPlan(
931
+ updated,
932
+ direction,
933
+ "persist-candidate-absent",
934
+ name,
935
+ )
936
+ candidate["state"] = "discarding"
937
+ candidate["displaced_identity"] = None
938
+ candidate["displaced_purpose"] = None
939
+ return ActivationRecoveryPlan(updated, direction, "persist-discarding", name)
940
+
941
+ for name, candidate_classification, physical in (
942
+ ("link", classification.link_candidate, classification.link),
943
+ ("manifest", classification.manifest_candidate, classification.manifest),
944
+ ):
945
+ candidate = updated["candidates"][name]
946
+ if candidate["purpose"] == "target":
947
+ continue
948
+ if candidate["purpose"] != expected_purpose:
949
+ raise IntegrityError("activation recovery candidate purpose changed")
950
+ state = candidate["state"]
951
+ desired_physical = "P" if plan_direction_is_previous(direction) else "T"
952
+ if state == "absent":
953
+ if physical == desired_physical or (name == "link" and physical == "B" and desired_physical in ("P", "T")):
954
+ continue
955
+ candidate["state"] = "building"
956
+ return ActivationRecoveryPlan(updated, direction, "start-repair-candidate", name)
957
+ if state == "building":
958
+ if candidate_classification not in (
959
+ "missing",
960
+ "recorded-owned",
961
+ "exact-unrecorded-purpose-object",
962
+ "exact-unrecorded-empty-regular",
963
+ ):
964
+ raise IntegrityError("activation recovery building candidate is unknown")
965
+ return ActivationRecoveryPlan(updated, direction, "resume-building-candidate", name)
966
+ if state == "ready":
967
+ if candidate_classification == "displaced-public":
968
+ _adopt_displaced_candidate(updated, name)
969
+ return ActivationRecoveryPlan(updated, direction, "persist-displaced-candidate", name)
970
+ if candidate_classification != "recorded-owned":
971
+ raise IntegrityError("activation recovery ready candidate is unknown")
972
+ if physical == desired_physical or (name == "link" and physical == "B" and desired_physical in ("P", "T")):
973
+ return ActivationRecoveryPlan(updated, direction, "persist-published", name)
974
+ evidence = classification.link_evidence if name == "link" else classification.manifest_evidence
975
+ return ActivationRecoveryPlan(
976
+ updated,
977
+ direction,
978
+ "publish-repair-candidate",
979
+ name,
980
+ evidence,
981
+ )
982
+ if state == "published":
983
+ candidate["state"] = "discarding"
984
+ return ActivationRecoveryPlan(updated, direction, "persist-discarding", name)
985
+
986
+ for name, physical, desired in (
987
+ ("link", classification.link, desired_link),
988
+ ("manifest", classification.manifest, desired_manifest),
989
+ ):
990
+ if physical == desired or (name == "link" and physical == "B" and desired in ("P", "T")):
991
+ continue
992
+ if direction == "rollback-absent":
993
+ evidence = classification.link_evidence if name == "link" else classification.manifest_evidence
994
+ return ActivationRecoveryPlan(
995
+ updated,
996
+ direction,
997
+ "delete-public",
998
+ name,
999
+ evidence,
1000
+ )
1001
+ purpose = "recovery-previous" if direction == "rollback-previous" else "recovery-target"
1002
+ updated["candidates"][name]["purpose"] = purpose
1003
+ return ActivationRecoveryPlan(updated, direction, "build-repair-candidate", name)
1004
+ return ActivationRecoveryPlan(updated, direction, "dispose-journal")
1005
+
1006
+
1007
+ def plan_direction_is_previous(direction):
1008
+ return direction == "rollback-previous"
1009
+
1010
+
1011
+ def recover_use_record(paths, record):
1012
+ # type: (JerryProxyPaths, ActivationRecord) -> ActivationRecoveryPlan
1013
+ """Reclassify and plan the next recovery step for one preflighted record."""
1014
+
1015
+ if not isinstance(record, ActivationRecord) or record.kind != "use":
1016
+ raise IntegrityError("invalid activation recovery record")
1017
+ return plan_activation_recovery(record.state, classify_activation(paths, record.state))
1018
+
1019
+
1020
+ def _writer_temporary(paths, operation, write_id):
1021
+ return paths.runtimes / (".use-%s.json.tmp-%s" % (operation, write_id()))
1022
+
1023
+
1024
+ def _write_activation_record(paths, journal_path, value, write_id, expected_identity=None):
1025
+ temporary = _writer_temporary(paths, value["operation"], write_id)
1026
+ try:
1027
+ with AnchoredDirectory(paths.runtimes) as runtimes:
1028
+ unused_payload, journal_identity = runtimes.write_json(
1029
+ (Path(journal_path).name,),
1030
+ value,
1031
+ (temporary.name,),
1032
+ replace_existing=expected_identity is not None,
1033
+ expected_destination_identity=expected_identity,
1034
+ )
1035
+ except ArchiveError as error:
1036
+ raise IntegrityError("unable to publish anchored activation journal: %s" % journal_path) from error
1037
+ return journal_identity
1038
+
1039
+
1040
+ def _require_activation_authority_value(journal, journal_identity, expected_value):
1041
+ try:
1042
+ with AnchoredDirectory(Path(journal).parent) as runtimes:
1043
+ value, unused_identity = runtimes.read_json(
1044
+ (Path(journal).name,),
1045
+ expected_identity=journal_identity,
1046
+ )
1047
+ if value != expected_value:
1048
+ raise IntegrityError("activation journal content changed")
1049
+ except (ArchiveError, OSError, ValueError, IntegrityError) as error:
1050
+ # Recovery authority may disappear or change identity/content after planning.
1051
+ raise IntegrityError("activation journal changed before recovery action: %s" % journal) from error
1052
+
1053
+
1054
+ class _ActivationJournalAuthority(object):
1055
+ """Track the exact authoritative activation record between transitions."""
1056
+
1057
+ def __init__(self, paths, journal_path, value, write_id, journal_identity=None):
1058
+ self.paths = paths
1059
+ self.journal_path = Path(journal_path)
1060
+ self.value = deepcopy(value)
1061
+ self.write_id = write_id
1062
+ self.journal_identity = journal_identity
1063
+
1064
+ @classmethod
1065
+ def from_record(cls, paths, record, write_id):
1066
+ return cls(
1067
+ paths,
1068
+ record.journal_path,
1069
+ record.state,
1070
+ write_id,
1071
+ journal_identity=record.journal_identity,
1072
+ )
1073
+
1074
+ def require(self):
1075
+ if self.journal_identity is None:
1076
+ return
1077
+ _require_activation_authority_value(
1078
+ self.journal_path,
1079
+ self.journal_identity,
1080
+ self.value,
1081
+ )
1082
+
1083
+ def publish(self, value):
1084
+ self.require()
1085
+ self.journal_identity = _write_activation_record(
1086
+ self.paths,
1087
+ self.journal_path,
1088
+ value,
1089
+ self.write_id,
1090
+ expected_identity=self.journal_identity,
1091
+ )
1092
+ self.value = deepcopy(value)
1093
+
1094
+
1095
+ def _publish_activation_value(paths, journal_path, value, write_id, authority=None):
1096
+ if authority is None:
1097
+ _write_activation_record(paths, journal_path, value, write_id)
1098
+ else:
1099
+ authority.publish(value)
1100
+
1101
+
1102
+ def _logical_from_installed(paths, installed_evidence, link, link_mode, manifest_payload):
1103
+ installed, executable_size, digest, unused_identity = installed_evidence
1104
+ executable = str(PurePosixPath(*installed.executable.relative_to(paths.root).parts))
1105
+ if digest != installed.executable_sha256:
1106
+ raise IntegrityError("activation source executable digest changed")
1107
+ return {
1108
+ "version": installed.version,
1109
+ "executable": executable,
1110
+ "executable_size": executable_size,
1111
+ "executable_sha256": digest,
1112
+ "link_mode": link_mode,
1113
+ "manifest_payload": manifest_payload,
1114
+ }
1115
+
1116
+
1117
+ def _manifest_payload(backend, logical, link, activated_at, link_mode):
1118
+ return {
1119
+ "name": backend,
1120
+ "version": logical["version"],
1121
+ "executable": logical["executable"],
1122
+ "link": link,
1123
+ "activated_at": activated_at,
1124
+ "link_mode": link_mode,
1125
+ }
1126
+
1127
+
1128
+ def _clear_candidate(candidate):
1129
+ candidate.update(
1130
+ {
1131
+ "state": "absent",
1132
+ "identity": None,
1133
+ "size": None,
1134
+ "sha256": None,
1135
+ "target": None,
1136
+ "displaced_identity": None,
1137
+ "displaced_purpose": None,
1138
+ }
1139
+ )
1140
+
1141
+
1142
+ def _candidate_area(paths, name):
1143
+ return paths.bin if name == "link" else paths.active
1144
+
1145
+
1146
+ def _candidate_path(paths, value, name):
1147
+ return paths.root / value["candidates"][name]["path"]
1148
+
1149
+
1150
+ def _public_path(paths, value, name):
1151
+ return paths.root / value[name if name == "link" else "manifest"]
1152
+
1153
+
1154
+ def _create_symlink_candidate(paths, value, target):
1155
+ path = _candidate_path(paths, value, "link")
1156
+ try:
1157
+ with AnchoredDirectory(paths.bin) as anchored:
1158
+ return anchored.create_symlink((path.name,), target)
1159
+ except ArchiveError as error:
1160
+ cause = error.__cause__
1161
+ if str(error) == "unable to create anchored symlink" and isinstance(cause, OSError):
1162
+ # Native symlink creation failures retain the existing public OSError contract.
1163
+ raise cause
1164
+ raise IntegrityError("activation candidate ancestor changed") from error
1165
+
1166
+
1167
+ def durable_replace(
1168
+ source,
1169
+ destination,
1170
+ expected_identity=None,
1171
+ expected_destination_identity=None,
1172
+ ):
1173
+ source = Path(source)
1174
+ destination = Path(destination)
1175
+ if source.parent != destination.parent:
1176
+ return _filesystem_durable_replace(source, destination)
1177
+ try:
1178
+ with AnchoredDirectory(source.parent) as anchored:
1179
+ outcome = anchored.replace(
1180
+ (source.name,),
1181
+ (destination.name,),
1182
+ expected_identity=expected_identity,
1183
+ replace_existing=expected_destination_identity is not None,
1184
+ expected_destination_identity=expected_destination_identity,
1185
+ )
1186
+ return (outcome,)
1187
+ except ArchiveError as error:
1188
+ raise IntegrityError("activation publication ancestor changed") from error
1189
+
1190
+
1191
+ def _verify_recovery_precondition(paths, value, name, evidence, candidate=False):
1192
+ if not isinstance(evidence, dict) or set(evidence) != {"classification", "identity"}:
1193
+ raise IntegrityError("activation recovery action has no stable precondition")
1194
+ path = _candidate_path(paths, value, name) if candidate else _public_path(paths, value, name)
1195
+ expected_identity = evidence["identity"]
1196
+ if expected_identity is None:
1197
+ if os.path.lexists(str(path)):
1198
+ raise IntegrityError("managed object changed after activation recovery planning")
1199
+ return
1200
+ if not identity_matches(path, expected_identity):
1201
+ raise IntegrityError("managed object changed after activation recovery planning")
1202
+ if candidate:
1203
+ actual = classify_candidate(paths, value, name)
1204
+ elif name == "link":
1205
+ actual = classify_public_link(paths, value)
1206
+ else:
1207
+ actual = classify_public_manifest(paths, value)
1208
+ if actual != evidence["classification"] or not identity_matches(path, expected_identity):
1209
+ raise IntegrityError("managed object changed after activation recovery planning")
1210
+
1211
+
1212
+ def _remove_candidate(paths, value, name):
1213
+ candidate = value["candidates"][name]
1214
+ path = _candidate_path(paths, value, name)
1215
+ if not os.path.lexists(str(path)):
1216
+ return
1217
+ allowed = (path,) if candidate["identity"]["file_type"] == "symlink" else ()
1218
+ removed = _secure_remove_tree(
1219
+ _candidate_area(paths, name),
1220
+ path,
1221
+ IntegrityError,
1222
+ allowed_symlinks=allowed,
1223
+ expected_identity=candidate["identity"],
1224
+ private_names=True,
1225
+ )
1226
+ if removed:
1227
+ flush_directory(path.parent)
1228
+
1229
+
1230
+ def _remove_public(paths, value, name, expected_identity):
1231
+ path = _public_path(paths, value, name)
1232
+ if not os.path.lexists(str(path)):
1233
+ return
1234
+ candidate = _candidate_path(paths, value, name)
1235
+ try:
1236
+ with AnchoredDirectory(_candidate_area(paths, name)) as parent:
1237
+ parent.replace(
1238
+ (path.name,),
1239
+ (candidate.name,),
1240
+ expected_identity=expected_identity,
1241
+ replace_existing=False,
1242
+ )
1243
+ except ArchiveError as error:
1244
+ raise IntegrityError("unable to isolate public activation state for recovery") from error
1245
+
1246
+
1247
+ def _open_regular_candidate(path):
1248
+ output = None
1249
+ try:
1250
+ with AnchoredDirectory(Path(path).parent) as anchored:
1251
+ output, identity = anchored.create_file((Path(path).name,))
1252
+ output.flush()
1253
+ flush_descriptor(output.fileno(), "empty activation candidate")
1254
+ anchored.flush()
1255
+ return output, identity
1256
+ except (ArchiveError, DurabilityError) as error:
1257
+ if output is not None:
1258
+ output.close()
1259
+ if isinstance(error, DurabilityError):
1260
+ raise
1261
+ raise IntegrityError("activation candidate ancestor changed") from error
1262
+
1263
+
1264
+ def _open_anchored_regular_candidate(paths, value, name):
1265
+ path = _candidate_path(paths, value, name)
1266
+ output, identity = _open_regular_candidate(path)
1267
+ try:
1268
+ if not identity_matches(path, identity):
1269
+ raise IntegrityError("activation candidate changed while being created")
1270
+ return output, identity
1271
+ except (OSError, IntegrityError):
1272
+ output.close()
1273
+ raise
1274
+
1275
+
1276
+ def _reopen_regular_candidate(paths, value, name, identity):
1277
+ path = _candidate_path(paths, value, name)
1278
+ try:
1279
+ with AnchoredDirectory(_candidate_area(paths, name)) as anchored:
1280
+ output, unused_identity = anchored.open_existing_file(
1281
+ (path.name,),
1282
+ writable=True,
1283
+ expected_identity=identity,
1284
+ )
1285
+ return output
1286
+ except ArchiveError as error:
1287
+ raise IntegrityError("activation candidate changed while reopening") from error
1288
+
1289
+
1290
+ def _prepare_regular_candidate(paths, journal_path, value, name, write_id, authority=None):
1291
+ if authority is not None:
1292
+ authority.require()
1293
+ candidate = value["candidates"][name]
1294
+ path = _candidate_path(paths, value, name)
1295
+ if candidate["identity"] is None:
1296
+ if os.path.lexists(str(path)):
1297
+ identity = capture_identity(path)
1298
+ status = path.lstat()
1299
+ if not stat.S_ISREG(status.st_mode) or status.st_size != 0:
1300
+ raise IntegrityError("unrecorded activation candidate is not empty")
1301
+ output = _reopen_regular_candidate(paths, value, name, identity)
1302
+ else:
1303
+ output, identity = _open_anchored_regular_candidate(paths, value, name)
1304
+ candidate["identity"] = identity
1305
+ try:
1306
+ _publish_activation_value(paths, journal_path, value, write_id, authority)
1307
+ except (OSError, IntegrityError):
1308
+ # A failed identity publication must not leak the candidate output stream.
1309
+ output.close()
1310
+ raise
1311
+ else:
1312
+ output = _reopen_regular_candidate(paths, value, name, candidate["identity"])
1313
+ return candidate, output
1314
+
1315
+
1316
+ def _mark_regular_candidate_ready(
1317
+ paths,
1318
+ journal_path,
1319
+ value,
1320
+ candidate,
1321
+ size,
1322
+ digest,
1323
+ write_id,
1324
+ ready_phase,
1325
+ authority=None,
1326
+ ):
1327
+ candidate.update(
1328
+ {
1329
+ "state": "ready",
1330
+ "size": size,
1331
+ "sha256": digest,
1332
+ "target": None,
1333
+ }
1334
+ )
1335
+ if ready_phase is not None:
1336
+ value["phase"] = ready_phase
1337
+ _publish_activation_value(paths, journal_path, value, write_id, authority)
1338
+
1339
+
1340
+ def _write_regular_candidate(
1341
+ paths,
1342
+ journal_path,
1343
+ value,
1344
+ name,
1345
+ payload,
1346
+ write_id,
1347
+ ready_phase=None,
1348
+ authority=None,
1349
+ ):
1350
+ candidate, output = _prepare_regular_candidate(
1351
+ paths,
1352
+ journal_path,
1353
+ value,
1354
+ name,
1355
+ write_id,
1356
+ authority=authority,
1357
+ )
1358
+ if authority is not None:
1359
+ authority.require()
1360
+ with output:
1361
+ os.ftruncate(output.fileno(), 0)
1362
+ written = 0
1363
+ while written < len(payload):
1364
+ block = payload[written : written + _COPY_CHUNK_SIZE]
1365
+ block_written = output.write(block)
1366
+ if (
1367
+ not isinstance(block_written, int)
1368
+ or isinstance(block_written, bool)
1369
+ or block_written <= 0
1370
+ or block_written > len(block)
1371
+ ):
1372
+ raise IntegrityError("activation candidate write made no valid progress")
1373
+ written += block_written
1374
+ output.flush()
1375
+ flush_descriptor(output.fileno(), "activation candidate")
1376
+ _mark_regular_candidate_ready(
1377
+ paths,
1378
+ journal_path,
1379
+ value,
1380
+ candidate,
1381
+ len(payload),
1382
+ hashlib.sha256(payload).hexdigest(),
1383
+ write_id,
1384
+ ready_phase,
1385
+ authority=authority,
1386
+ )
1387
+
1388
+
1389
+ def _copy_regular_candidate(
1390
+ paths,
1391
+ journal_path,
1392
+ value,
1393
+ name,
1394
+ source,
1395
+ expected_size,
1396
+ expected_digest,
1397
+ write_id,
1398
+ ready_phase=None,
1399
+ authority=None,
1400
+ ):
1401
+ candidate, output = _prepare_regular_candidate(
1402
+ paths,
1403
+ journal_path,
1404
+ value,
1405
+ name,
1406
+ write_id,
1407
+ authority=authority,
1408
+ )
1409
+ if authority is not None:
1410
+ authority.require()
1411
+ source = Path(source)
1412
+ try:
1413
+ source_parts = source.relative_to(paths.backends).parts
1414
+ except ValueError as error:
1415
+ raise IntegrityError("activation source executable escapes installed backends") from error
1416
+ digest = hashlib.sha256()
1417
+ written = 0
1418
+ with output:
1419
+ os.ftruncate(output.fileno(), 0)
1420
+ try:
1421
+ with AnchoredDirectory(paths.backends) as installed_tree:
1422
+ input_stream, source_identity = installed_tree.open_existing_file(source_parts)
1423
+ with input_stream:
1424
+ before = os.fstat(input_stream.fileno())
1425
+ while written < expected_size:
1426
+ block = input_stream.read(min(_COPY_CHUNK_SIZE, expected_size - written))
1427
+ if not block:
1428
+ raise IntegrityError("activation source executable ended during copy")
1429
+ block_written = 0
1430
+ while block_written < len(block):
1431
+ progress = output.write(block[block_written:])
1432
+ if (
1433
+ not isinstance(progress, int)
1434
+ or isinstance(progress, bool)
1435
+ or progress <= 0
1436
+ or progress > len(block) - block_written
1437
+ ):
1438
+ raise IntegrityError("activation candidate write made no valid progress")
1439
+ digest.update(block[block_written : block_written + progress])
1440
+ block_written += progress
1441
+ written += progress
1442
+ if input_stream.read(1):
1443
+ raise IntegrityError("activation source executable grew during copy")
1444
+ after = os.fstat(input_stream.fileno())
1445
+ before_snapshot = (
1446
+ int(before.st_dev),
1447
+ int(before.st_ino),
1448
+ stat.S_IFMT(before.st_mode),
1449
+ int(before.st_size),
1450
+ int(before.st_mtime_ns),
1451
+ int(before.st_ctime_ns),
1452
+ )
1453
+ after_snapshot = (
1454
+ int(after.st_dev),
1455
+ int(after.st_ino),
1456
+ stat.S_IFMT(after.st_mode),
1457
+ int(after.st_size),
1458
+ int(after.st_mtime_ns),
1459
+ int(after.st_ctime_ns),
1460
+ )
1461
+ if before_snapshot != after_snapshot:
1462
+ raise IntegrityError("activation source executable changed during copy")
1463
+ installed_tree.assert_bound()
1464
+ if not identity_matches(source, source_identity):
1465
+ raise IntegrityError("activation source executable changed while opening")
1466
+ except ArchiveError as error:
1467
+ raise IntegrityError("activation source executable changed while opening") from error
1468
+ output.flush()
1469
+ flush_descriptor(output.fileno(), "activation candidate")
1470
+ actual_digest = digest.hexdigest()
1471
+ if written != expected_size or actual_digest != expected_digest:
1472
+ raise IntegrityError("activation source executable changed during copy")
1473
+ _mark_regular_candidate_ready(
1474
+ paths,
1475
+ journal_path,
1476
+ value,
1477
+ candidate,
1478
+ written,
1479
+ actual_digest,
1480
+ write_id,
1481
+ ready_phase,
1482
+ authority=authority,
1483
+ )
1484
+
1485
+
1486
+ def _write_link_candidate(
1487
+ paths,
1488
+ journal_path,
1489
+ value,
1490
+ logical,
1491
+ write_id,
1492
+ ready_phase=None,
1493
+ authority=None,
1494
+ ):
1495
+ candidate = value["candidates"]["link"]
1496
+ path = _candidate_path(paths, value, "link")
1497
+ if authority is not None:
1498
+ authority.require()
1499
+ if logical["link_mode"] == "symlink":
1500
+ target = os.path.relpath(str(paths.root / logical["executable"]), str(path.parent))
1501
+ created_identity = None
1502
+ if not os.path.lexists(str(path)):
1503
+ created_identity = _create_symlink_candidate(paths, value, target)
1504
+ elif not path.is_symlink() or os.readlink(str(path)) != target:
1505
+ raise IntegrityError("unrecorded activation symlink candidate changed")
1506
+ candidate.update(
1507
+ {
1508
+ "state": "ready",
1509
+ "identity": created_identity or capture_identity(path),
1510
+ "size": None,
1511
+ "sha256": None,
1512
+ "target": target,
1513
+ }
1514
+ )
1515
+ if ready_phase is not None:
1516
+ value["phase"] = ready_phase
1517
+ _publish_activation_value(paths, journal_path, value, write_id, authority)
1518
+ return
1519
+ _copy_regular_candidate(
1520
+ paths,
1521
+ journal_path,
1522
+ value,
1523
+ "link",
1524
+ paths.root / logical["executable"],
1525
+ logical["executable_size"],
1526
+ logical["executable_sha256"],
1527
+ write_id,
1528
+ ready_phase=ready_phase,
1529
+ authority=authority,
1530
+ )
1531
+
1532
+
1533
+ def _write_manifest_candidate(
1534
+ paths,
1535
+ journal_path,
1536
+ value,
1537
+ logical,
1538
+ write_id,
1539
+ ready_phase=None,
1540
+ authority=None,
1541
+ ):
1542
+ payload = _canonical_bytes(logical["manifest_payload"])
1543
+ _write_regular_candidate(
1544
+ paths,
1545
+ journal_path,
1546
+ value,
1547
+ "manifest",
1548
+ payload,
1549
+ write_id,
1550
+ ready_phase=ready_phase,
1551
+ authority=authority,
1552
+ )
1553
+
1554
+
1555
+ def _publish_candidate(
1556
+ paths,
1557
+ journal_path,
1558
+ value,
1559
+ name,
1560
+ write_id,
1561
+ expected_public_identity,
1562
+ displaced_purpose,
1563
+ published_phase=None,
1564
+ authority=None,
1565
+ ):
1566
+ if authority is not None:
1567
+ authority.require()
1568
+ candidate_path = _candidate_path(paths, value, name)
1569
+ public_path = _public_path(paths, value, name)
1570
+ if os.path.lexists(str(candidate_path)):
1571
+ if not identity_matches(candidate_path, value["candidates"][name]["identity"]):
1572
+ raise IntegrityError("activation candidate changed before publication")
1573
+ candidate = value["candidates"][name]
1574
+ if candidate["identity"]["file_type"] == "regular":
1575
+ try:
1576
+ with AnchoredDirectory(_candidate_area(paths, name)) as parent:
1577
+ size, digest, opened_identity = parent.file_evidence(
1578
+ (candidate_path.name,),
1579
+ expected_identity=candidate["identity"],
1580
+ )
1581
+ if (
1582
+ opened_identity != candidate["identity"]
1583
+ or size != candidate["size"]
1584
+ or digest != candidate["sha256"]
1585
+ ):
1586
+ raise IntegrityError("activation candidate content changed before publication")
1587
+ except (ArchiveError, OSError) as error:
1588
+ # Ready regular candidates must remain readable through publication.
1589
+ raise IntegrityError("activation candidate content changed before publication") from error
1590
+ candidate["displaced_identity"] = expected_public_identity
1591
+ candidate["displaced_purpose"] = displaced_purpose
1592
+ if authority is not None:
1593
+ authority.publish(value)
1594
+ durable_replace(
1595
+ candidate_path,
1596
+ public_path,
1597
+ candidate["identity"],
1598
+ expected_destination_identity=expected_public_identity,
1599
+ )
1600
+ else:
1601
+ if not identity_matches(public_path, value["candidates"][name]["identity"]):
1602
+ raise IntegrityError("activation candidate disappeared before publication")
1603
+ flush_directory(public_path.parent)
1604
+ value["candidates"][name]["state"] = "published"
1605
+ value["candidates"][name]["displaced_identity"] = None
1606
+ value["candidates"][name]["displaced_purpose"] = None
1607
+ if published_phase is not None:
1608
+ value["phase"] = published_phase
1609
+ _publish_activation_value(paths, journal_path, value, write_id, authority)
1610
+
1611
+
1612
+ class ActivationTransaction(object):
1613
+ """One journaled active-backend publication under the home-wide lock."""
1614
+
1615
+ def __init__(self, paths, platform_info, journal_path, value, write_id):
1616
+ self.paths = paths
1617
+ self.platform_info = platform_info
1618
+ self.journal_path = Path(journal_path)
1619
+ self.value = value
1620
+ self._write_id = write_id
1621
+ self._authority = _ActivationJournalAuthority(
1622
+ paths,
1623
+ self.journal_path,
1624
+ value,
1625
+ write_id,
1626
+ )
1627
+
1628
+ @classmethod
1629
+ def prepare(
1630
+ cls,
1631
+ paths,
1632
+ platform_info,
1633
+ name,
1634
+ version,
1635
+ activated_at=None,
1636
+ operation=None,
1637
+ write_id=None,
1638
+ ):
1639
+ """Publish activation intent before creating either candidate object."""
1640
+
1641
+ spec = get_backend(name)
1642
+ version = spec.normalize_version(version)
1643
+ operation = operation or secrets.token_hex(16)
1644
+ if _OPERATION_PATTERN.fullmatch(operation) is None:
1645
+ raise ValueError("activation operation must be 32 lowercase hexadecimal characters")
1646
+ link = "bin/%s" % spec.executable_filename(platform_info)
1647
+ manifest = "active/%s.json" % spec.name
1648
+ installed = load_installed_manifest_evidence(
1649
+ paths,
1650
+ paths.backends / spec.name / version / "manifest.json",
1651
+ )
1652
+ target = _logical_from_installed(paths, installed, link, None, None)
1653
+ previous_state = load_active_state(paths, spec.name, platform_info)
1654
+ previous = None
1655
+ if previous_state is not None:
1656
+ active, previous_payload = previous_state
1657
+ previous_installed = load_installed_manifest_evidence(
1658
+ paths,
1659
+ paths.backends / spec.name / active.version / "manifest.json",
1660
+ )
1661
+ previous = _logical_from_installed(
1662
+ paths,
1663
+ previous_installed,
1664
+ link,
1665
+ active.link_mode,
1666
+ previous_payload,
1667
+ )
1668
+ value = {
1669
+ "kind": "use",
1670
+ "operation": operation,
1671
+ "phase": "prepared",
1672
+ "backend": spec.name,
1673
+ "link": link,
1674
+ "manifest": manifest,
1675
+ "previous": previous,
1676
+ "target": target,
1677
+ "candidates": {
1678
+ "link": {
1679
+ "path": "bin/.%s.use-%s.candidate" % (Path(link).name, operation),
1680
+ "purpose": "target",
1681
+ "state": "absent",
1682
+ "identity": None,
1683
+ "size": None,
1684
+ "sha256": None,
1685
+ "target": None,
1686
+ "displaced_identity": None,
1687
+ "displaced_purpose": None,
1688
+ },
1689
+ "manifest": {
1690
+ "path": "active/.%s.use-%s.candidate.json" % (spec.name, operation),
1691
+ "purpose": "target",
1692
+ "state": "absent",
1693
+ "identity": None,
1694
+ "size": None,
1695
+ "sha256": None,
1696
+ "target": None,
1697
+ "displaced_identity": None,
1698
+ "displaced_purpose": None,
1699
+ },
1700
+ },
1701
+ "recovery": None,
1702
+ }
1703
+ transaction = cls(
1704
+ paths,
1705
+ platform_info,
1706
+ paths.runtimes / (".use-%s.json" % operation),
1707
+ value,
1708
+ write_id or (lambda: secrets.token_hex(16)),
1709
+ )
1710
+ if os.path.lexists(str(transaction.journal_path)):
1711
+ raise IntegrityError("activation journal already exists")
1712
+ transaction.activated_at = activated_at or datetime.now(timezone.utc).isoformat()
1713
+ transaction._authority.publish(value)
1714
+ load_use_journal(paths, transaction.journal_path, platform_info)
1715
+ return transaction
1716
+
1717
+ def execute(self):
1718
+ """Build, publish, validate, and commit the target active pair."""
1719
+
1720
+ self._authority.require()
1721
+ initial = classify_activation(self.paths, self.value)
1722
+ expected_link_states = ("M",) if self.value["previous"] is None else ("P", "B")
1723
+ expected_manifest_state = "M" if self.value["previous"] is None else "P"
1724
+ if initial.link_candidate != "missing" or initial.manifest_candidate != "missing":
1725
+ raise IntegrityError("unrecorded activation candidate is not empty")
1726
+ if initial.link not in expected_link_states or initial.manifest != expected_manifest_state:
1727
+ raise IntegrityError("active state changed before activation candidate construction")
1728
+ expected_public_identities = {
1729
+ "link": initial.link_evidence["identity"] if initial.link_evidence is not None else None,
1730
+ "manifest": (
1731
+ initial.manifest_evidence["identity"] if initial.manifest_evidence is not None else None
1732
+ ),
1733
+ }
1734
+ displaced_purpose = "previous" if self.value["previous"] is not None else None
1735
+ target = self.value["target"]
1736
+ link_candidate = self.value["candidates"]["link"]
1737
+ link_path = _candidate_path(self.paths, self.value, "link")
1738
+ target_path = os.path.relpath(
1739
+ str(self.paths.root / target["executable"]),
1740
+ str(link_path.parent),
1741
+ )
1742
+ try:
1743
+ created_identity = _create_symlink_candidate(self.paths, self.value, target_path)
1744
+ except (OSError, NotImplementedError) as error:
1745
+ if not _windows_symlink_fallback_allowed(error):
1746
+ raise
1747
+ target["link_mode"] = "copy"
1748
+ target["manifest_payload"] = _manifest_payload(
1749
+ self.value["backend"], target, self.value["link"], self.activated_at, "copy"
1750
+ )
1751
+ link_candidate["state"] = "building"
1752
+ self.value["phase"] = "link-building"
1753
+ self._authority.publish(self.value)
1754
+ _write_link_candidate(
1755
+ self.paths,
1756
+ self.journal_path,
1757
+ self.value,
1758
+ target,
1759
+ self._write_id,
1760
+ ready_phase="link-ready",
1761
+ authority=self._authority,
1762
+ )
1763
+ else:
1764
+ target["link_mode"] = "symlink"
1765
+ target["manifest_payload"] = _manifest_payload(
1766
+ self.value["backend"], target, self.value["link"], self.activated_at, "symlink"
1767
+ )
1768
+ link_candidate.update(
1769
+ {
1770
+ "state": "ready",
1771
+ "identity": created_identity,
1772
+ "size": None,
1773
+ "sha256": None,
1774
+ "target": target_path,
1775
+ }
1776
+ )
1777
+ self.value["phase"] = "link-ready"
1778
+ self._authority.publish(self.value)
1779
+
1780
+ self.value["candidates"]["manifest"]["state"] = "building"
1781
+ self.value["phase"] = "manifest-building"
1782
+ self._authority.publish(self.value)
1783
+ _write_manifest_candidate(
1784
+ self.paths,
1785
+ self.journal_path,
1786
+ self.value,
1787
+ target,
1788
+ self._write_id,
1789
+ ready_phase="candidates-ready",
1790
+ authority=self._authority,
1791
+ )
1792
+
1793
+ _publish_candidate(
1794
+ self.paths,
1795
+ self.journal_path,
1796
+ self.value,
1797
+ "link",
1798
+ self._write_id,
1799
+ expected_public_identities["link"],
1800
+ displaced_purpose,
1801
+ published_phase="link-published",
1802
+ authority=self._authority,
1803
+ )
1804
+ _publish_candidate(
1805
+ self.paths,
1806
+ self.journal_path,
1807
+ self.value,
1808
+ "manifest",
1809
+ self._write_id,
1810
+ expected_public_identities["manifest"],
1811
+ displaced_purpose,
1812
+ published_phase="manifest-published",
1813
+ authority=self._authority,
1814
+ )
1815
+ self._authority.require()
1816
+ classification = classify_activation(self.paths, self.value)
1817
+ if (classification.link, classification.manifest) not in (("T", "T"), ("B", "T")):
1818
+ raise IntegrityError("published activation pair failed validation")
1819
+ self.value["phase"] = "committed"
1820
+ self._authority.publish(self.value)
1821
+ recover_use_transactions(self.paths, self.platform_info)
1822
+ active = load_active_state(self.paths, self.value["backend"], self.platform_info)
1823
+ if active is None:
1824
+ raise IntegrityError("committed activation state disappeared")
1825
+ return active[0]
1826
+
1827
+
1828
+ def _execute_recovery_plan(paths, record, plan, platform_info, write_id):
1829
+ authority = _ActivationJournalAuthority.from_record(paths, record, write_id)
1830
+ authority.require()
1831
+ value = plan.journal
1832
+ journal = record.journal_path
1833
+ name = plan.object_name
1834
+ if plan.action in (
1835
+ "persist-direction",
1836
+ "persist-discarding",
1837
+ "build-repair-candidate",
1838
+ "start-repair-candidate",
1839
+ ):
1840
+ authority.publish(value)
1841
+ return
1842
+ if plan.action == "persist-candidate-absent":
1843
+ flush_directory(_candidate_path(paths, value, name).parent)
1844
+ authority.publish(value)
1845
+ return
1846
+ if plan.action == "pin-unrecorded-candidate":
1847
+ candidate = value["candidates"][name]
1848
+ _verify_recovery_precondition(paths, value, name, plan.precondition, candidate=True)
1849
+ identity = plan.precondition["identity"]
1850
+ candidate["state"] = "discarding"
1851
+ candidate["identity"] = identity
1852
+ if identity["file_type"] == "symlink":
1853
+ desired = value["target"]
1854
+ candidate["target"] = os.path.relpath(desired["executable"], "bin")
1855
+ authority.publish(value)
1856
+ return
1857
+ if plan.action == "delete-candidate":
1858
+ _remove_candidate(paths, value, name)
1859
+ return
1860
+ if plan.action == "delete-public":
1861
+ _verify_recovery_precondition(paths, value, name, plan.precondition)
1862
+ _remove_public(paths, value, name, plan.precondition["identity"])
1863
+ return
1864
+ if plan.action == "resume-building-candidate":
1865
+ logical = value["previous"] if plan.direction == "rollback-previous" else value["target"]
1866
+ if name == "link":
1867
+ _write_link_candidate(
1868
+ paths,
1869
+ journal,
1870
+ value,
1871
+ logical,
1872
+ write_id,
1873
+ authority=authority,
1874
+ )
1875
+ else:
1876
+ _write_manifest_candidate(
1877
+ paths,
1878
+ journal,
1879
+ value,
1880
+ logical,
1881
+ write_id,
1882
+ authority=authority,
1883
+ )
1884
+ return
1885
+ if plan.action == "publish-repair-candidate":
1886
+ _verify_recovery_precondition(paths, value, name, plan.precondition)
1887
+ displaced_classification = plan.precondition["classification"]
1888
+ displaced_purpose = None
1889
+ if displaced_classification in ("P", "B"):
1890
+ displaced_purpose = "previous"
1891
+ elif displaced_classification == "T":
1892
+ displaced_purpose = "target"
1893
+ _publish_candidate(
1894
+ paths,
1895
+ journal,
1896
+ value,
1897
+ name,
1898
+ write_id,
1899
+ plan.precondition["identity"],
1900
+ displaced_purpose,
1901
+ authority=authority,
1902
+ )
1903
+ return
1904
+ if plan.action == "persist-displaced-candidate":
1905
+ authority.publish(value)
1906
+ return
1907
+ if plan.action == "persist-published":
1908
+ value["candidates"][name]["state"] = "published"
1909
+ value["candidates"][name]["displaced_identity"] = None
1910
+ value["candidates"][name]["displaced_purpose"] = None
1911
+ authority.publish(value)
1912
+ return
1913
+ if plan.action == "dispose-journal":
1914
+ removed = False
1915
+ for temporary, identity in record.temporaries:
1916
+ if os.path.lexists(str(temporary)):
1917
+ removed = (
1918
+ _secure_remove_tree(
1919
+ paths.runtimes,
1920
+ temporary,
1921
+ IntegrityError,
1922
+ expected_identity=identity,
1923
+ private_names=True,
1924
+ )
1925
+ or removed
1926
+ )
1927
+ if os.path.lexists(str(journal)):
1928
+ removed = (
1929
+ _secure_remove_tree(
1930
+ paths.runtimes,
1931
+ journal,
1932
+ IntegrityError,
1933
+ expected_identity=record.journal_identity,
1934
+ private_names=True,
1935
+ )
1936
+ or removed
1937
+ )
1938
+ if removed:
1939
+ flush_directory(paths.runtimes)
1940
+ return
1941
+ raise IntegrityError("unsupported activation recovery action: %s" % plan.action)
1942
+
1943
+
1944
+ def _flush_public_parents_before_recovery(paths, record, flushed_operations):
1945
+ if record.state["recovery"] is None or record.operation in flushed_operations:
1946
+ return
1947
+ flush_directory(paths.bin)
1948
+ flush_directory(paths.active)
1949
+ flushed_operations.add(record.operation)
1950
+
1951
+
1952
+ def recover_use_transactions(paths, platform_info, write_id=None):
1953
+ # type: (JerryProxyPaths, PlatformInfo, Optional[Callable]) -> None
1954
+ """Recover every authoritative use journal in lexical operation order."""
1955
+
1956
+ write_id = write_id or (lambda: secrets.token_hex(16))
1957
+ flushed_operations = set()
1958
+ while True:
1959
+ records = discover_use_journals(paths, platform_info)
1960
+ if not records:
1961
+ return
1962
+ progressed = False
1963
+ for record in records:
1964
+ _flush_public_parents_before_recovery(paths, record, flushed_operations)
1965
+ plan = recover_use_record(paths, record)
1966
+ _execute_recovery_plan(paths, record, plan, platform_info, write_id)
1967
+ progressed = True
1968
+ if not progressed:
1969
+ raise IntegrityError("activation recovery made no progress")
1970
+
1971
+
1972
+ def _recover_use_operation(paths, platform_info, operation, write_id=None):
1973
+ """Recover one preflighted activation operation to convergence."""
1974
+
1975
+ write_id = write_id or (lambda: secrets.token_hex(16))
1976
+ flushed_operations = set()
1977
+ while True:
1978
+ records = discover_use_journals(paths, platform_info)
1979
+ selected = [record for record in records if record.operation == operation]
1980
+ if not selected:
1981
+ return
1982
+ if len(selected) != 1:
1983
+ raise IntegrityError("duplicate activation recovery operation")
1984
+ record = selected[0]
1985
+ _flush_public_parents_before_recovery(paths, record, flushed_operations)
1986
+ plan = recover_use_record(paths, record)
1987
+ _execute_recovery_plan(paths, record, plan, platform_info, write_id)