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.
- jerryproxy/__init__.py +5 -0
- jerryproxy/__main__.py +8 -0
- jerryproxy/backend/__init__.py +13 -0
- jerryproxy/backend/activation.py +1987 -0
- jerryproxy/backend/anchored.py +2041 -0
- jerryproxy/backend/archive.py +810 -0
- jerryproxy/backend/archive_preflight.py +724 -0
- jerryproxy/backend/catalog.py +290 -0
- jerryproxy/backend/download.py +235 -0
- jerryproxy/backend/durable.py +116 -0
- jerryproxy/backend/identity.py +290 -0
- jerryproxy/backend/installation.py +685 -0
- jerryproxy/backend/manager.py +1009 -0
- jerryproxy/backend/model.py +125 -0
- jerryproxy/backend/platform.py +71 -0
- jerryproxy/backend/recovery.py +96 -0
- jerryproxy/backend/registry.py +306 -0
- jerryproxy/backend/relay.py +156 -0
- jerryproxy/backend/removal.py +1814 -0
- jerryproxy/backend/state.py +379 -0
- jerryproxy/cli/__init__.py +59 -0
- jerryproxy/cli/_common.py +220 -0
- jerryproxy/cli/_completion.py +119 -0
- jerryproxy/cli/backend/__init__.py +85 -0
- jerryproxy/cli/backend/clean.py +129 -0
- jerryproxy/cli/backend/current.py +72 -0
- jerryproxy/cli/backend/install.py +194 -0
- jerryproxy/cli/backend/list.py +280 -0
- jerryproxy/cli/backend/uninstall.py +85 -0
- jerryproxy/cli/backend/use.py +46 -0
- jerryproxy/cli/backend/verify.py +64 -0
- jerryproxy/cli/backend/which.py +61 -0
- jerryproxy/cli/doctor.py +41 -0
- jerryproxy/cli/home.py +14 -0
- jerryproxy/cli/node/__init__.py +24 -0
- jerryproxy/cli/node/list.py +57 -0
- jerryproxy/cli/self_check.py +43 -0
- jerryproxy/cli/server.py +487 -0
- jerryproxy/cli/subscription/__init__.py +71 -0
- jerryproxy/cli/subscription/_common.py +252 -0
- jerryproxy/cli/subscription/add.py +76 -0
- jerryproxy/cli/subscription/list.py +48 -0
- jerryproxy/cli/subscription/refresh.py +32 -0
- jerryproxy/cli/subscription/remove.py +38 -0
- jerryproxy/cli/subscription/replace.py +63 -0
- jerryproxy/cli/subscription/show.py +26 -0
- jerryproxy/cli/subscription/validate.py +28 -0
- jerryproxy/config/__init__.py +5 -0
- jerryproxy/config/meta.py +7 -0
- jerryproxy/data/__init__.py +49 -0
- jerryproxy/data/mihomo.json +7710 -0
- jerryproxy/data/sing-box.json +20984 -0
- jerryproxy/data/v2ray.json +18944 -0
- jerryproxy/data/xray.json +15457 -0
- jerryproxy/errors.py +102 -0
- jerryproxy/home.py +206 -0
- jerryproxy/lock.py +195 -0
- jerryproxy/runtime/__init__.py +19 -0
- jerryproxy/runtime/guardian.py +335 -0
- jerryproxy/runtime/health.py +325 -0
- jerryproxy/runtime/interfaces.py +95 -0
- jerryproxy/runtime/mihomo.py +1791 -0
- jerryproxy/runtime/session.py +885 -0
- jerryproxy/selfcheck/__init__.py +36 -0
- jerryproxy/selfcheck/backend.py +69 -0
- jerryproxy/selfcheck/dependencies.py +83 -0
- jerryproxy/selfcheck/environment.py +101 -0
- jerryproxy/selfcheck/fixtures.py +76 -0
- jerryproxy/selfcheck/processes.py +538 -0
- jerryproxy/selfcheck/recovery.py +259 -0
- jerryproxy/selfcheck/relay.py +287 -0
- jerryproxy/selfcheck/resources.py +86 -0
- jerryproxy/selfcheck/result.py +128 -0
- jerryproxy/selfcheck/runner.py +173 -0
- jerryproxy/selfcheck/runtime.py +332 -0
- jerryproxy/selfcheck/subscription.py +252 -0
- jerryproxy/subscription/__init__.py +46 -0
- jerryproxy/subscription/audit.py +115 -0
- jerryproxy/subscription/interfaces.py +52 -0
- jerryproxy/subscription/manager.py +552 -0
- jerryproxy/subscription/model.py +115 -0
- jerryproxy/subscription/redaction.py +98 -0
- jerryproxy/subscription/storage.py +1227 -0
- jerryproxy/subscription/transport.py +812 -0
- jerryproxy/utils/__init__.py +1 -0
- jerryproxy/utils/fs.py +147 -0
- jerryproxy-0.1.0.dist-info/LICENSE +201 -0
- jerryproxy-0.1.0.dist-info/METADATA +741 -0
- jerryproxy-0.1.0.dist-info/RECORD +93 -0
- jerryproxy-0.1.0.dist-info/THIRD_PARTY_LICENSES.md +80 -0
- jerryproxy-0.1.0.dist-info/WHEEL +5 -0
- jerryproxy-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|