@mriekki/project-forge 4.26.0 → 4.27.1
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.
- package/assets/authentication/distribution.json +90 -0
- package/assets/components.json +18 -4
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +152 -6
- package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
- package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
- package/assets/generated/authentication/tools/household_connect.py +145 -0
- package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
- package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
- package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
- package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
- package/assets/generated/core/tools/setup_maintenance/maintenance.py +162 -6
- package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
- package/package.json +1 -1
- package/src/cli.mjs +32 -3
- package/src/engine.mjs +87 -5
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed Auth Kit recovery capture, isolated materialization and verification.
|
|
3
|
+
|
|
4
|
+
Low-level library only: never stops a service, replaces live Project data, or
|
|
5
|
+
claims application/session readiness. Deployment must independently verify
|
|
6
|
+
quiescence and perform dynamic protector/policy checks before live traffic.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
SHA = re.compile(r"^[0-9a-f]{64}$")
|
|
20
|
+
ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
21
|
+
ALLOWED_POLICIES = {"preserve-authentication", "invalidate-authentication"}
|
|
22
|
+
MAX_KEYS = 128
|
|
23
|
+
MAX_KEY_BYTES = 1024 * 1024
|
|
24
|
+
MAX_DB_BYTES = 1024 * 1024 * 1024 * 8
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecoveryError(ValueError):
|
|
28
|
+
"""A recovery qualification invariant has failed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _object(value: object, fields: set[str], context: str) -> dict:
|
|
32
|
+
if type(value) is not dict or set(value) != fields:
|
|
33
|
+
raise RecoveryError(f"{context}: unexpected or missing fields")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _regular(path: Path, *, directory: bool = False) -> os.stat_result:
|
|
38
|
+
"""Never follow a symlink in an owned recovery-set path."""
|
|
39
|
+
for parent in (path, *path.parents):
|
|
40
|
+
if parent.is_symlink():
|
|
41
|
+
raise RecoveryError("Recovery set contains a symlink")
|
|
42
|
+
try:
|
|
43
|
+
status = path.lstat()
|
|
44
|
+
except OSError as exc:
|
|
45
|
+
raise RecoveryError("Recovery set member is missing") from exc
|
|
46
|
+
if (stat.S_ISDIR(status.st_mode) if directory else
|
|
47
|
+
stat.S_ISREG(status.st_mode)) is not True:
|
|
48
|
+
raise RecoveryError("Unexpected recovery-set filesystem member")
|
|
49
|
+
return status
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_declaration(value: object) -> dict:
|
|
53
|
+
"""Validate non-secret Project-owned recovery intent, not runtime paths."""
|
|
54
|
+
row = _object(value, {
|
|
55
|
+
"schemaVersion", "projectId", "authenticationPolicy",
|
|
56
|
+
"database", "keyRing", "protectorGeneration",
|
|
57
|
+
"providerRecoveryDependencies"
|
|
58
|
+
}, "declaration")
|
|
59
|
+
if type(row["schemaVersion"]) is not int or row["schemaVersion"] != 1:
|
|
60
|
+
raise RecoveryError("Unsupported recovery declaration version")
|
|
61
|
+
if type(row["projectId"]) is not str or not ID.fullmatch(row["projectId"]):
|
|
62
|
+
raise RecoveryError("Invalid Project identity")
|
|
63
|
+
if type(row["authenticationPolicy"]) is not str or row["authenticationPolicy"] not in ALLOWED_POLICIES:
|
|
64
|
+
raise RecoveryError("Explicit authentication recovery policy required")
|
|
65
|
+
database = _object(row["database"], {"artifact", "format"}, "database")
|
|
66
|
+
if database != {"artifact": "auth.sqlite", "format": "sqlite3"}:
|
|
67
|
+
raise RecoveryError("Unsupported database artifact")
|
|
68
|
+
ring = _object(row["keyRing"], {"directory", "required"}, "keyRing")
|
|
69
|
+
if ring != {"directory": "keys", "required": True}:
|
|
70
|
+
raise RecoveryError("Persistent DataProtection key ring is required")
|
|
71
|
+
generation = row["protectorGeneration"]
|
|
72
|
+
if type(generation) is not str or not re.fullmatch(r"[0-9a-f]{32}", generation):
|
|
73
|
+
raise RecoveryError("Invalid protector generation fingerprint")
|
|
74
|
+
dependencies = row["providerRecoveryDependencies"]
|
|
75
|
+
if type(dependencies) is not list or len(dependencies) > 16:
|
|
76
|
+
raise RecoveryError("Invalid external dependency declaration")
|
|
77
|
+
if (any(type(x) is not str or not ID.fullmatch(x) for x in dependencies) or
|
|
78
|
+
len(set(dependencies)) != len(dependencies)):
|
|
79
|
+
raise RecoveryError("External dependencies must be unique non-secret IDs")
|
|
80
|
+
return row
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sha256(path: Path, limit: int) -> str:
|
|
84
|
+
status = _regular(path)
|
|
85
|
+
if status.st_size <= 0 or status.st_size > limit or status.st_nlink != 1:
|
|
86
|
+
raise RecoveryError("Recovery artifact size/link count not accepted")
|
|
87
|
+
digest = hashlib.sha256()
|
|
88
|
+
with path.open("rb") as stream:
|
|
89
|
+
while chunk := stream.read(1024 * 1024):
|
|
90
|
+
digest.update(chunk)
|
|
91
|
+
if _regular(path).st_size != status.st_size:
|
|
92
|
+
raise RecoveryError("Recovery artifact changed during validation")
|
|
93
|
+
return digest.hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def inspect_isolated_set(root: Path, declaration: dict, receipt: object) -> dict:
|
|
97
|
+
"""Check inert set integrity; NEVER report applicationRecoveryReady=true.
|
|
98
|
+
|
|
99
|
+
root must be a private, isolated, disposable directory unrelated to the
|
|
100
|
+
live Project data. The caller is responsible for verifying that placement.
|
|
101
|
+
"""
|
|
102
|
+
validate_declaration(declaration)
|
|
103
|
+
record = _object(receipt, {
|
|
104
|
+
"schemaVersion", "projectId", "protectorGeneration", "policy",
|
|
105
|
+
"databaseSha256", "keys"
|
|
106
|
+
}, "receipt")
|
|
107
|
+
if type(record["schemaVersion"]) is not int or record["schemaVersion"] != 1:
|
|
108
|
+
raise RecoveryError("Unsupported recovery receipt")
|
|
109
|
+
if (type(record["projectId"]) is not str or
|
|
110
|
+
type(record["protectorGeneration"]) is not str or
|
|
111
|
+
type(record["policy"]) is not str or
|
|
112
|
+
record["projectId"] != declaration["projectId"] or
|
|
113
|
+
record["protectorGeneration"] != declaration["protectorGeneration"] or
|
|
114
|
+
record["policy"] != declaration["authenticationPolicy"]):
|
|
115
|
+
raise RecoveryError("Recovery policy/identity/generation mismatch")
|
|
116
|
+
root = Path(root)
|
|
117
|
+
_regular(root, directory=True)
|
|
118
|
+
db = root / "auth.sqlite"
|
|
119
|
+
keys = root / "keys"
|
|
120
|
+
_regular(keys, directory=True)
|
|
121
|
+
if type(record["databaseSha256"]) is not str or not SHA.fullmatch(record["databaseSha256"]):
|
|
122
|
+
raise RecoveryError("Invalid database hash")
|
|
123
|
+
members = sorted(x.name for x in root.iterdir())
|
|
124
|
+
if members not in (["auth.sqlite", "keys"], ["auth.sqlite", "keys", "receipt.json"]):
|
|
125
|
+
raise RecoveryError("Unknown or partial recovery-set top-level files")
|
|
126
|
+
if "receipt.json" in members:
|
|
127
|
+
receipt_path = root / "receipt.json"
|
|
128
|
+
if _regular(receipt_path).st_size > 32 * 1024:
|
|
129
|
+
raise RecoveryError("Recovery receipt is oversized")
|
|
130
|
+
try:
|
|
131
|
+
stored = json.loads(receipt_path.read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
133
|
+
raise RecoveryError("Recovery receipt cannot be read") from exc
|
|
134
|
+
if stored != receipt:
|
|
135
|
+
raise RecoveryError("Private recovery receipt mismatch")
|
|
136
|
+
if any((root / x).exists() or (root / x).is_symlink() for x in
|
|
137
|
+
("auth.sqlite-wal", "auth.sqlite-shm", "auth.sqlite-journal")):
|
|
138
|
+
raise RecoveryError("Uncheckpointed database transaction artifacts")
|
|
139
|
+
if _sha256(db, MAX_DB_BYTES) != record["databaseSha256"]:
|
|
140
|
+
raise RecoveryError("Database artifact hash mismatch")
|
|
141
|
+
entries = record["keys"]
|
|
142
|
+
if type(entries) is not dict or not 1 <= len(entries) <= MAX_KEYS:
|
|
143
|
+
raise RecoveryError("Missing or excessive key material")
|
|
144
|
+
if any(type(name) is not str for name in entries):
|
|
145
|
+
raise RecoveryError("Malformed key inventory names")
|
|
146
|
+
observed = sorted(x.name for x in keys.iterdir())
|
|
147
|
+
if observed != sorted(entries):
|
|
148
|
+
raise RecoveryError("Unrecognized or missing key material")
|
|
149
|
+
for name, digest in entries.items():
|
|
150
|
+
if (type(name) is not str or not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", name)
|
|
151
|
+
or type(digest) is not str or not SHA.fullmatch(digest)):
|
|
152
|
+
raise RecoveryError("Malformed key inventory")
|
|
153
|
+
if _sha256(keys / name, MAX_KEY_BYTES) != digest:
|
|
154
|
+
raise RecoveryError("Key material hash mismatch")
|
|
155
|
+
|
|
156
|
+
# SQLite URI read-only mode prevents a verifier from accidentally creating
|
|
157
|
+
# a fresh database; mode=ro must not be construed as application validation.
|
|
158
|
+
conn = None
|
|
159
|
+
try:
|
|
160
|
+
conn = sqlite3.connect(db.as_uri() + "?mode=ro&immutable=1", uri=True)
|
|
161
|
+
integrity = conn.execute("PRAGMA integrity_check").fetchone()
|
|
162
|
+
fk = conn.execute("PRAGMA foreign_key_check").fetchone()
|
|
163
|
+
version = conn.execute("PRAGMA user_version").fetchone()
|
|
164
|
+
tables = {
|
|
165
|
+
item[0] for item in conn.execute(
|
|
166
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
167
|
+
).fetchall()
|
|
168
|
+
}
|
|
169
|
+
expected_columns = {
|
|
170
|
+
"accounts": {
|
|
171
|
+
"id", "email", "normalized_email", "email_verified",
|
|
172
|
+
"is_admin", "disabled", "created_utc",
|
|
173
|
+
},
|
|
174
|
+
"external_identities": {"account_id", "issuer", "subject"},
|
|
175
|
+
"invitations": {
|
|
176
|
+
"id", "recipient_email", "recipient_normalized", "token_hash",
|
|
177
|
+
"expires_utc", "consumed_utc", "created_by", "created_utc",
|
|
178
|
+
},
|
|
179
|
+
"sessions": {
|
|
180
|
+
"token_hash", "account_id", "created_utc", "expires_utc",
|
|
181
|
+
"revoked_utc",
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
if (integrity != ("ok",) or fk is not None or version != (1,) or
|
|
185
|
+
tables != set(expected_columns)):
|
|
186
|
+
raise RecoveryError("Auth Kit schema or SQLite integrity invalid")
|
|
187
|
+
for name, columns in expected_columns.items():
|
|
188
|
+
found = {
|
|
189
|
+
row[1] for row in conn.execute(
|
|
190
|
+
"PRAGMA table_info(" + name + ")").fetchall()
|
|
191
|
+
}
|
|
192
|
+
if found != columns:
|
|
193
|
+
raise RecoveryError("Auth Kit schema column mismatch")
|
|
194
|
+
except sqlite3.DatabaseError as exc:
|
|
195
|
+
raise RecoveryError("SQLite recovery artifact is invalid") from exc
|
|
196
|
+
finally:
|
|
197
|
+
if conn is not None:
|
|
198
|
+
conn.close()
|
|
199
|
+
return {
|
|
200
|
+
"backupArtifactIntegrityVerified": True,
|
|
201
|
+
"isolatedArtifactInspectionComplete": True,
|
|
202
|
+
"protectorViabilityVerified": False,
|
|
203
|
+
"authenticationPolicyVerified": False,
|
|
204
|
+
"applicationRecoveryReady": False,
|
|
205
|
+
"scheduledBackupVerified": False,
|
|
206
|
+
"alertInboxReceiptVerified": False,
|
|
207
|
+
"unresolvedRequiredDependencies": [
|
|
208
|
+
"dynamic-protector-proof", "authentication-policy-proof",
|
|
209
|
+
*declaration["providerRecoveryDependencies"],
|
|
210
|
+
],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _exclusive_publish_directory(stage: Path, destination: Path) -> None:
|
|
216
|
+
"""Atomic directory publication with OS-level NO-REPLACE semantics.
|
|
217
|
+
|
|
218
|
+
A preceding exists() check is insufficient: on POSIX, rename() can
|
|
219
|
+
silently replace an existing *empty directory*. Unsupported filesystems
|
|
220
|
+
fail closed; we do not fall back to an unsafe check-then-rename.
|
|
221
|
+
"""
|
|
222
|
+
import ctypes
|
|
223
|
+
import errno
|
|
224
|
+
import sys
|
|
225
|
+
|
|
226
|
+
source = os.fsencode(stage)
|
|
227
|
+
target = os.fsencode(destination)
|
|
228
|
+
if sys.platform.startswith("linux"):
|
|
229
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
230
|
+
renameat2 = getattr(libc, "renameat2", None)
|
|
231
|
+
if renameat2 is None:
|
|
232
|
+
raise RecoveryError("Atomic no-replace directory rename unavailable")
|
|
233
|
+
renameat2.argtypes = [
|
|
234
|
+
ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p,
|
|
235
|
+
ctypes.c_uint,
|
|
236
|
+
]
|
|
237
|
+
renameat2.restype = ctypes.c_int
|
|
238
|
+
status = renameat2(-100, source, -100, target, 1) # RENAME_NOREPLACE
|
|
239
|
+
if status != 0:
|
|
240
|
+
failure = ctypes.get_errno()
|
|
241
|
+
if failure in (errno.EEXIST, errno.ENOTEMPTY):
|
|
242
|
+
raise RecoveryError("Recovery destination already exists")
|
|
243
|
+
raise RecoveryError("Atomic recovery publication unavailable")
|
|
244
|
+
elif sys.platform == "darwin":
|
|
245
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
246
|
+
renamex = getattr(libc, "renamex_np", None)
|
|
247
|
+
if renamex is None:
|
|
248
|
+
raise RecoveryError("Atomic no-replace directory rename unavailable")
|
|
249
|
+
renamex.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
|
|
250
|
+
renamex.restype = ctypes.c_int
|
|
251
|
+
if renamex(source, target, 0x00000004) != 0: # RENAME_EXCL
|
|
252
|
+
failure = ctypes.get_errno()
|
|
253
|
+
if failure in (errno.EEXIST, errno.ENOTEMPTY):
|
|
254
|
+
raise RecoveryError("Recovery destination already exists")
|
|
255
|
+
raise RecoveryError("Atomic recovery publication unavailable")
|
|
256
|
+
elif os.name == "nt":
|
|
257
|
+
# Windows MoveFile-style os.rename refuses an existing destination.
|
|
258
|
+
try:
|
|
259
|
+
os.rename(stage, destination)
|
|
260
|
+
except FileExistsError as exc:
|
|
261
|
+
raise RecoveryError("Recovery destination already exists") from exc
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise RecoveryError("Atomic recovery publication unavailable") from exc
|
|
264
|
+
else:
|
|
265
|
+
raise RecoveryError("Atomic no-replace directory rename unsupported")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _disjoint(a: Path, b: Path) -> bool:
|
|
269
|
+
"""Compare normalized lexical paths; any symlink ancestor is rejected separately."""
|
|
270
|
+
x = Path(os.path.abspath(a))
|
|
271
|
+
y = Path(os.path.abspath(b))
|
|
272
|
+
return x != y and x not in y.parents and y not in x.parents
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _copy_verified_file(source: Path, destination: Path, expected_hash: str,
|
|
276
|
+
limit: int) -> None:
|
|
277
|
+
"""Create an exclusive private file from a no-follow source descriptor."""
|
|
278
|
+
import shutil
|
|
279
|
+
|
|
280
|
+
if _sha256(source, limit) != expected_hash:
|
|
281
|
+
raise RecoveryError("Recovery source changed before copy")
|
|
282
|
+
before = _regular(source)
|
|
283
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
284
|
+
handle = os.open(source, flags)
|
|
285
|
+
try:
|
|
286
|
+
opened = os.fstat(handle)
|
|
287
|
+
if (opened.st_dev, opened.st_ino, opened.st_size) != (
|
|
288
|
+
before.st_dev, before.st_ino, before.st_size
|
|
289
|
+
):
|
|
290
|
+
raise RecoveryError("Recovery artifact changed during open")
|
|
291
|
+
with os.fdopen(os.dup(handle), "rb") as inp, destination.open("xb") as out:
|
|
292
|
+
if os.name != "nt":
|
|
293
|
+
os.chmod(destination, 0o600)
|
|
294
|
+
shutil.copyfileobj(inp, out, 1024 * 1024)
|
|
295
|
+
out.flush()
|
|
296
|
+
os.fsync(out.fileno())
|
|
297
|
+
finally:
|
|
298
|
+
os.close(handle)
|
|
299
|
+
if _sha256(destination, limit) != expected_hash:
|
|
300
|
+
raise RecoveryError("Recovery copy is incomplete or corrupted")
|
|
301
|
+
if _sha256(source, limit) != expected_hash:
|
|
302
|
+
raise RecoveryError("Recovery source changed during copy")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def materialize_disposable_restore(
|
|
306
|
+
backup_set: Path, destination: Path, live_data_root: Path,
|
|
307
|
+
declaration: dict, receipt: dict,
|
|
308
|
+
) -> dict:
|
|
309
|
+
"""Materialize a hash-verified private restore into a NEW disposable path.
|
|
310
|
+
|
|
311
|
+
Does NOT run an app, rewrite live data, rotate keys, preserve sessions by
|
|
312
|
+
itself or assert application recovery. The caller must supply the exact
|
|
313
|
+
current live-data root and isolate the new destination from it and the
|
|
314
|
+
backup set. No Production restore CLI is exposed.
|
|
315
|
+
"""
|
|
316
|
+
import shutil
|
|
317
|
+
import tempfile
|
|
318
|
+
|
|
319
|
+
validate_declaration(declaration)
|
|
320
|
+
backup_set = Path(os.path.abspath(backup_set))
|
|
321
|
+
destination = Path(os.path.abspath(destination))
|
|
322
|
+
live_data_root = Path(os.path.abspath(live_data_root))
|
|
323
|
+
_regular(backup_set, directory=True)
|
|
324
|
+
_regular(live_data_root, directory=True)
|
|
325
|
+
parent = destination.parent
|
|
326
|
+
_regular(parent, directory=True)
|
|
327
|
+
if (not _disjoint(destination, live_data_root) or
|
|
328
|
+
not _disjoint(destination, backup_set) or
|
|
329
|
+
not _disjoint(parent, live_data_root) or
|
|
330
|
+
not _disjoint(parent, backup_set) or
|
|
331
|
+
not _disjoint(backup_set, live_data_root)):
|
|
332
|
+
raise RecoveryError("Restore, backup and live data must be isolated")
|
|
333
|
+
if os.name != "nt" and stat.S_IMODE(parent.stat().st_mode) & 0o077:
|
|
334
|
+
raise RecoveryError("Disposable restore parent must be owner-private")
|
|
335
|
+
if destination.exists() or destination.is_symlink():
|
|
336
|
+
raise RecoveryError("Refusing to replace existing disposable data")
|
|
337
|
+
if not (backup_set / "receipt.json").is_file():
|
|
338
|
+
raise RecoveryError("Immutable recovery set requires a private receipt")
|
|
339
|
+
inspect_isolated_set(backup_set, declaration, receipt)
|
|
340
|
+
stage = Path(tempfile.mkdtemp(prefix=".auth-restore-", dir=parent))
|
|
341
|
+
try:
|
|
342
|
+
if os.name != "nt":
|
|
343
|
+
os.chmod(stage, 0o700)
|
|
344
|
+
(stage / "keys").mkdir(mode=0o700)
|
|
345
|
+
_copy_verified_file(backup_set / "auth.sqlite", stage / "auth.sqlite",
|
|
346
|
+
receipt["databaseSha256"], MAX_DB_BYTES)
|
|
347
|
+
for name, digest in receipt["keys"].items():
|
|
348
|
+
_copy_verified_file(backup_set / "keys" / name, stage / "keys" / name,
|
|
349
|
+
digest, MAX_KEY_BYTES)
|
|
350
|
+
_copy_verified_file(
|
|
351
|
+
backup_set / "receipt.json", stage / "receipt.json",
|
|
352
|
+
_sha256(backup_set / "receipt.json", 32 * 1024), 32 * 1024)
|
|
353
|
+
inspect_isolated_set(stage, declaration, receipt)
|
|
354
|
+
_exclusive_publish_directory(stage, destination)
|
|
355
|
+
return {
|
|
356
|
+
"isolatedRestoreMaterialized": True,
|
|
357
|
+
"backupArtifactIntegrityVerified": True,
|
|
358
|
+
"protectorViabilityVerified": False,
|
|
359
|
+
"authenticationPolicyVerified": False,
|
|
360
|
+
"applicationRecoveryReady": False,
|
|
361
|
+
"normalTrafficAllowed": False,
|
|
362
|
+
}
|
|
363
|
+
finally:
|
|
364
|
+
if stage.exists():
|
|
365
|
+
shutil.rmtree(stage)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
RECOVERY_SET_NAME = re.compile(r"^(20[0-9]{6}T[0-9]{6}Z)-[0-9a-f]{8,32}$")
|
|
370
|
+
MAX_INVENTORY_SETS = 1000
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _valid_set_name(name: str) -> bool:
|
|
374
|
+
from datetime import datetime
|
|
375
|
+
|
|
376
|
+
if type(name) is not str:
|
|
377
|
+
return False
|
|
378
|
+
match = RECOVERY_SET_NAME.fullmatch(name)
|
|
379
|
+
if match is None:
|
|
380
|
+
return False
|
|
381
|
+
try:
|
|
382
|
+
datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ")
|
|
383
|
+
except ValueError:
|
|
384
|
+
return False
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def plan_verified_retention(
|
|
389
|
+
sets_root: Path, declaration: dict, *, keep_last: int = 7
|
|
390
|
+
) -> dict:
|
|
391
|
+
"""Read-only retention preview of fully verified immutable recovery sets.
|
|
392
|
+
|
|
393
|
+
Fail closed if any set is corrupt, incomplete, symlinked, misidentified or
|
|
394
|
+
ambiguously named; never delete a set, stage, or application directory.
|
|
395
|
+
A retention plan is NOT a scheduled backup/restore/notification proof.
|
|
396
|
+
"""
|
|
397
|
+
validate_declaration(declaration)
|
|
398
|
+
if type(keep_last) is not int or not 2 <= keep_last <= 365:
|
|
399
|
+
raise RecoveryError("Retention minimum must preserve at least two sets")
|
|
400
|
+
sets_root = Path(sets_root)
|
|
401
|
+
root_status = _regular(sets_root, directory=True)
|
|
402
|
+
if os.name != "nt" and stat.S_IMODE(root_status.st_mode) & 0o077:
|
|
403
|
+
raise RecoveryError("Recovery-set inventory must be owner-private")
|
|
404
|
+
names = sorted(p.name for p in sets_root.iterdir())
|
|
405
|
+
if len(names) > MAX_INVENTORY_SETS:
|
|
406
|
+
raise RecoveryError("Recovery-set inventory count exceeds safe limit")
|
|
407
|
+
if any(not _valid_set_name(name) for name in names):
|
|
408
|
+
raise RecoveryError("Unknown, staging or malformed recovery-set name")
|
|
409
|
+
verified = []
|
|
410
|
+
for name in names:
|
|
411
|
+
candidate = sets_root / name
|
|
412
|
+
_regular(candidate, directory=True)
|
|
413
|
+
receipt_path = candidate / "receipt.json"
|
|
414
|
+
if _regular(receipt_path).st_size > 32 * 1024:
|
|
415
|
+
raise RecoveryError("Recovery receipt exceeds size limit")
|
|
416
|
+
try:
|
|
417
|
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
|
418
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
419
|
+
raise RecoveryError("Recovery set receipt is not readable") from exc
|
|
420
|
+
report = inspect_isolated_set(candidate, declaration, receipt)
|
|
421
|
+
if report["backupArtifactIntegrityVerified"] is not True:
|
|
422
|
+
raise RecoveryError("Unverified recovery set in retention inventory")
|
|
423
|
+
verified.append(name)
|
|
424
|
+
eligible = verified[:-keep_last] if len(verified) > keep_last else []
|
|
425
|
+
return {
|
|
426
|
+
"recoverySetCount": len(verified),
|
|
427
|
+
"verifiedRecoverySetNames": verified,
|
|
428
|
+
"retainedRecoverySetNames": verified[-keep_last:],
|
|
429
|
+
"retentionCandidateNames": eligible,
|
|
430
|
+
"retentionPlanOnly": True,
|
|
431
|
+
"deletionPerformed": False,
|
|
432
|
+
"scheduledBackupVerified": False,
|
|
433
|
+
"alertInboxReceiptVerified": False,
|
|
434
|
+
"applicationRecoveryReady": False,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def apply_reviewed_disposable_retention(
|
|
440
|
+
sets_root: Path, lock_directory: Path, declaration: dict,
|
|
441
|
+
reviewed_plan: dict, *, keep_last: int = 7,
|
|
442
|
+
environment: str, authorize_disposable_delete: bool = False,
|
|
443
|
+
max_delete: int = 32,
|
|
444
|
+
) -> dict:
|
|
445
|
+
"""Prune only a reviewed subset of verified *disposable* backup sets.
|
|
446
|
+
|
|
447
|
+
This is a low-level, opt-in Linux primitive, not a scheduled operation.
|
|
448
|
+
The caller must independently prove the actual generated-Project owner,
|
|
449
|
+
disposal-only environment, quiesced-capture success, separation from live
|
|
450
|
+
data, and application-level recovery readiness. A complete, unchanged,
|
|
451
|
+
read-only retention plan must have been reviewed before calling.
|
|
452
|
+
A partial filesystem failure is fail-closed and requires manual inspection;
|
|
453
|
+
this function cannot make multi-directory deletion atomic.
|
|
454
|
+
"""
|
|
455
|
+
if (sys.platform != "linux" or environment != "disposable" or
|
|
456
|
+
authorize_disposable_delete is not True):
|
|
457
|
+
raise RecoveryError("Explicit disposable Linux retention authorization required")
|
|
458
|
+
import fcntl
|
|
459
|
+
|
|
460
|
+
if type(max_delete) is not int or not 1 <= max_delete <= 32:
|
|
461
|
+
raise RecoveryError("Disposable retention deletion bound is invalid")
|
|
462
|
+
sets_root = Path(sets_root)
|
|
463
|
+
lock_directory = Path(lock_directory)
|
|
464
|
+
if (not sets_root.is_absolute() or not lock_directory.is_absolute()
|
|
465
|
+
or sets_root == lock_directory or sets_root in lock_directory.parents
|
|
466
|
+
or lock_directory in sets_root.parents):
|
|
467
|
+
raise RecoveryError("Reviewed backup inventory and lock must be isolated absolute paths")
|
|
468
|
+
root_info = _regular(sets_root, directory=True)
|
|
469
|
+
lock_info = _regular(lock_directory, directory=True)
|
|
470
|
+
if (stat.S_IMODE(root_info.st_mode) & 0o077 or
|
|
471
|
+
stat.S_IMODE(lock_info.st_mode) & 0o077 or
|
|
472
|
+
root_info.st_uid != os.geteuid() or
|
|
473
|
+
lock_info.st_uid != os.geteuid()):
|
|
474
|
+
raise RecoveryError("Disposable retention directories must be owner-owned and private")
|
|
475
|
+
lock_path = lock_directory / "auth-retention.lock"
|
|
476
|
+
lock_fd = -1
|
|
477
|
+
root_fd = -1
|
|
478
|
+
deleted: list[str] = []
|
|
479
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
480
|
+
|
|
481
|
+
def checked_regular(directory_fd: int, name: str) -> None:
|
|
482
|
+
info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
483
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
484
|
+
raise RecoveryError("Unexpected retention set member; deletion blocked")
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
lock_fd = os.open(
|
|
488
|
+
lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
489
|
+
lock_stat = os.fstat(lock_fd)
|
|
490
|
+
if (not stat.S_ISREG(lock_stat.st_mode) or lock_stat.st_nlink != 1 or
|
|
491
|
+
stat.S_IMODE(lock_stat.st_mode) & 0o077):
|
|
492
|
+
raise RecoveryError("Disposable retention lock is not owner-private")
|
|
493
|
+
try:
|
|
494
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
495
|
+
except BlockingIOError:
|
|
496
|
+
raise RecoveryError("Another disposable retention operation is running") from None
|
|
497
|
+
|
|
498
|
+
# No unlink, rename or rmdir occurs until every set has been checked,
|
|
499
|
+
# and the inventory is identical to the caller's exact reviewed plan.
|
|
500
|
+
plan = plan_verified_retention(sets_root, declaration, keep_last=keep_last)
|
|
501
|
+
if type(reviewed_plan) is not dict or reviewed_plan != plan:
|
|
502
|
+
raise RecoveryError("Disposable retention inventory differs from reviewed plan")
|
|
503
|
+
selected = plan["retentionCandidateNames"][:max_delete]
|
|
504
|
+
root_fd = os.open(sets_root, flags)
|
|
505
|
+
opened = os.fstat(root_fd)
|
|
506
|
+
if (opened.st_dev, opened.st_ino) != (root_info.st_dev, root_info.st_ino):
|
|
507
|
+
raise RecoveryError("Disposable retention inventory changed during open")
|
|
508
|
+
|
|
509
|
+
for name in selected:
|
|
510
|
+
# Anchor all destructive operations to no-follow directory file
|
|
511
|
+
# descriptors. Never call recursive deletion on a path.
|
|
512
|
+
source_info = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
|
|
513
|
+
if not stat.S_ISDIR(source_info.st_mode):
|
|
514
|
+
raise RecoveryError("Disposable retention candidate is not a directory")
|
|
515
|
+
set_fd = os.open(name, flags, dir_fd=root_fd)
|
|
516
|
+
try:
|
|
517
|
+
opened_set = os.fstat(set_fd)
|
|
518
|
+
if (opened_set.st_dev, opened_set.st_ino) != (
|
|
519
|
+
source_info.st_dev, source_info.st_ino):
|
|
520
|
+
raise RecoveryError("Disposable retention candidate changed during open")
|
|
521
|
+
if sorted(os.listdir(set_fd)) != ["auth.sqlite", "keys", "receipt.json"]:
|
|
522
|
+
raise RecoveryError("Disposable retention candidate changed after verification")
|
|
523
|
+
checked_regular(set_fd, "auth.sqlite")
|
|
524
|
+
checked_regular(set_fd, "receipt.json")
|
|
525
|
+
key_fd = os.open("keys", flags, dir_fd=set_fd)
|
|
526
|
+
try:
|
|
527
|
+
key_names = sorted(os.listdir(key_fd))
|
|
528
|
+
if not 1 <= len(key_names) <= MAX_KEYS:
|
|
529
|
+
raise RecoveryError("Disposable retention key inventory changed")
|
|
530
|
+
for key_name in key_names:
|
|
531
|
+
if not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", key_name):
|
|
532
|
+
raise RecoveryError("Disposable retention key name changed")
|
|
533
|
+
checked_regular(key_fd, key_name)
|
|
534
|
+
# Verify all candidate names BEFORE the first unlink.
|
|
535
|
+
for key_name in key_names:
|
|
536
|
+
os.unlink(key_name, dir_fd=key_fd)
|
|
537
|
+
os.fsync(key_fd)
|
|
538
|
+
finally:
|
|
539
|
+
os.close(key_fd)
|
|
540
|
+
os.rmdir("keys", dir_fd=set_fd)
|
|
541
|
+
os.unlink("receipt.json", dir_fd=set_fd)
|
|
542
|
+
os.unlink("auth.sqlite", dir_fd=set_fd)
|
|
543
|
+
os.fsync(set_fd)
|
|
544
|
+
finally:
|
|
545
|
+
os.close(set_fd)
|
|
546
|
+
os.rmdir(name, dir_fd=root_fd)
|
|
547
|
+
os.fsync(root_fd)
|
|
548
|
+
deleted.append(name)
|
|
549
|
+
|
|
550
|
+
# No assumption that deleting a valid old set proves application
|
|
551
|
+
# restore, scheduler operation or delivered alerts.
|
|
552
|
+
after = plan_verified_retention(sets_root, declaration, keep_last=keep_last)
|
|
553
|
+
return {
|
|
554
|
+
"deletedRecoverySetNames": deleted,
|
|
555
|
+
"retainedRecoverySetNames": after["verifiedRecoverySetNames"],
|
|
556
|
+
"deletionPerformed": bool(deleted),
|
|
557
|
+
"retentionPlanOnly": False,
|
|
558
|
+
"scheduledBackupVerified": False,
|
|
559
|
+
"alertInboxReceiptVerified": False,
|
|
560
|
+
"applicationRecoveryReady": False,
|
|
561
|
+
}
|
|
562
|
+
except OSError:
|
|
563
|
+
raise RecoveryError(
|
|
564
|
+
"Disposable retention filesystem operation failed; inspect the private inventory"
|
|
565
|
+
) from None
|
|
566
|
+
finally:
|
|
567
|
+
if root_fd != -1:
|
|
568
|
+
os.close(root_fd)
|
|
569
|
+
if lock_fd != -1:
|
|
570
|
+
os.close(lock_fd)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def capture_quiesced_set(
|
|
575
|
+
source: Path, destination: Path, declaration: dict, runtime_guard
|
|
576
|
+
) -> dict:
|
|
577
|
+
"""Stage a private, complete SQLite/key-ring set and atomically publish it.
|
|
578
|
+
|
|
579
|
+
This is a low-level primitive, NOT a service-quiescence mechanism. The
|
|
580
|
+
caller must supply a non-mutating guard backed by verified runtime
|
|
581
|
+
introspection, rechecking the exact stopped service before and after
|
|
582
|
+
capture. This function cannot establish that the guard itself is honest;
|
|
583
|
+
it must not be used directly as an operator-ready restore/backup CLI.
|
|
584
|
+
Never call on a Production host without a reviewed Deployment Kit driver.
|
|
585
|
+
"""
|
|
586
|
+
import shutil
|
|
587
|
+
import tempfile
|
|
588
|
+
|
|
589
|
+
validate_declaration(declaration)
|
|
590
|
+
if not callable(runtime_guard):
|
|
591
|
+
raise RecoveryError("An independently verified runtime guard is required")
|
|
592
|
+
source = Path(source)
|
|
593
|
+
destination = Path(destination)
|
|
594
|
+
if not _valid_set_name(destination.name):
|
|
595
|
+
raise RecoveryError("Recovery set name must be a valid UTC timestamp and random suffix")
|
|
596
|
+
_regular(source, directory=True)
|
|
597
|
+
parent = destination.parent
|
|
598
|
+
_regular(parent, directory=True)
|
|
599
|
+
if destination.exists() or destination.is_symlink():
|
|
600
|
+
raise RecoveryError("Refusing to overwrite an existing recovery set")
|
|
601
|
+
# Backup sets must not live within or contain the live application's data.
|
|
602
|
+
if source == destination or source in destination.parents or destination in source.parents:
|
|
603
|
+
raise RecoveryError("Recovery set and source storage must be isolated")
|
|
604
|
+
if os.name != "nt" and stat.S_IMODE(parent.stat().st_mode) & 0o077:
|
|
605
|
+
raise RecoveryError("Recovery-set parent must be owner-private")
|
|
606
|
+
if sorted(p.name for p in source.iterdir()) != ["auth.sqlite", "keys"]:
|
|
607
|
+
raise RecoveryError("Unexpected source artifact or uncheckpointed WAL")
|
|
608
|
+
db = source / "auth.sqlite"
|
|
609
|
+
keys = source / "keys"
|
|
610
|
+
db_status = _regular(db)
|
|
611
|
+
_regular(keys, directory=True)
|
|
612
|
+
if db_status.st_size <= 0 or db_status.st_size > MAX_DB_BYTES:
|
|
613
|
+
raise RecoveryError("Invalid source database size")
|
|
614
|
+
key_names = sorted(p.name for p in keys.iterdir())
|
|
615
|
+
if not 1 <= len(key_names) <= MAX_KEYS:
|
|
616
|
+
raise RecoveryError("Persistent key ring is missing or excessive")
|
|
617
|
+
for name in key_names:
|
|
618
|
+
if not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", name):
|
|
619
|
+
raise RecoveryError("Unrecognized keyring file")
|
|
620
|
+
st = _regular(keys / name)
|
|
621
|
+
if st.st_nlink != 1 or not 0 < st.st_size <= MAX_KEY_BYTES:
|
|
622
|
+
raise RecoveryError("Unsupported keyring file size or hard link")
|
|
623
|
+
|
|
624
|
+
def guard() -> dict:
|
|
625
|
+
observed = runtime_guard()
|
|
626
|
+
if (type(observed) is not dict or
|
|
627
|
+
set(observed) != {"stopped", "runtimeId", "imageDigest"} or
|
|
628
|
+
observed["stopped"] is not True or
|
|
629
|
+
type(observed["runtimeId"]) is not str or
|
|
630
|
+
re.fullmatch(r"[0-9a-f]{64}", observed["runtimeId"]) is None or
|
|
631
|
+
type(observed["imageDigest"]) is not str or
|
|
632
|
+
re.fullmatch(r"sha256:[0-9a-f]{64}", observed["imageDigest"]) is None):
|
|
633
|
+
raise RecoveryError("Runtime quiescence/identity was not proven")
|
|
634
|
+
return observed
|
|
635
|
+
|
|
636
|
+
before = guard()
|
|
637
|
+
stage = Path(tempfile.mkdtemp(prefix=".auth-recovery-", dir=parent))
|
|
638
|
+
try:
|
|
639
|
+
os.chmod(stage, 0o700)
|
|
640
|
+
(stage / "keys").mkdir(mode=0o700)
|
|
641
|
+
# Read-only immutable source rejects accidental live SQLite creation.
|
|
642
|
+
# SQLite backup produces a single consistent database without a WAL.
|
|
643
|
+
conn = None
|
|
644
|
+
dest_db = None
|
|
645
|
+
try:
|
|
646
|
+
conn = sqlite3.connect(db.as_uri() + "?mode=ro&immutable=1", uri=True)
|
|
647
|
+
dest_db = sqlite3.connect(stage / "auth.sqlite")
|
|
648
|
+
conn.backup(dest_db)
|
|
649
|
+
dest_db.commit()
|
|
650
|
+
except sqlite3.DatabaseError as exc:
|
|
651
|
+
raise RecoveryError("Quiesced SQLite capture failed") from exc
|
|
652
|
+
finally:
|
|
653
|
+
if dest_db is not None:
|
|
654
|
+
dest_db.close()
|
|
655
|
+
if conn is not None:
|
|
656
|
+
conn.close()
|
|
657
|
+
os.chmod(stage / "auth.sqlite", 0o600)
|
|
658
|
+
receipt_keys: dict[str, str] = {}
|
|
659
|
+
for name in key_names:
|
|
660
|
+
source_file = keys / name
|
|
661
|
+
expected = _regular(source_file)
|
|
662
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
663
|
+
handle = os.open(source_file, flags)
|
|
664
|
+
try:
|
|
665
|
+
opened = os.fstat(handle)
|
|
666
|
+
if ((opened.st_dev, opened.st_ino, opened.st_size) !=
|
|
667
|
+
(expected.st_dev, expected.st_ino, expected.st_size)):
|
|
668
|
+
raise RecoveryError("Keyring changed during capture")
|
|
669
|
+
dest_file = stage / "keys" / name
|
|
670
|
+
with os.fdopen(os.dup(handle), "rb") as inp, dest_file.open("xb") as out:
|
|
671
|
+
os.chmod(dest_file, 0o600)
|
|
672
|
+
shutil.copyfileobj(inp, out, 1024 * 1024)
|
|
673
|
+
out.flush()
|
|
674
|
+
os.fsync(out.fileno())
|
|
675
|
+
receipt_keys[name] = _sha256(dest_file, MAX_KEY_BYTES)
|
|
676
|
+
finally:
|
|
677
|
+
os.close(handle)
|
|
678
|
+
if guard() != before:
|
|
679
|
+
raise RecoveryError("Stopped runtime identity changed during capture")
|
|
680
|
+
receipt = {
|
|
681
|
+
"schemaVersion": 1,
|
|
682
|
+
"projectId": declaration["projectId"],
|
|
683
|
+
"protectorGeneration": declaration["protectorGeneration"],
|
|
684
|
+
"policy": declaration["authenticationPolicy"],
|
|
685
|
+
"databaseSha256": _sha256(stage / "auth.sqlite", MAX_DB_BYTES),
|
|
686
|
+
"keys": receipt_keys,
|
|
687
|
+
}
|
|
688
|
+
# Inspect the private set before publication. Runtime/key continuity
|
|
689
|
+
# and application readiness still demand separate dynamic validation.
|
|
690
|
+
# Keep the non-secret manifest inside the private recovery directory,
|
|
691
|
+
# so a later isolated verifier does not depend on a transient return.
|
|
692
|
+
receipt_path = stage / "receipt.json"
|
|
693
|
+
with receipt_path.open("x", encoding="utf-8") as output:
|
|
694
|
+
os.chmod(receipt_path, 0o600)
|
|
695
|
+
json.dump(receipt, output, sort_keys=True, separators=(",", ":"))
|
|
696
|
+
output.write("\n")
|
|
697
|
+
output.flush()
|
|
698
|
+
os.fsync(output.fileno())
|
|
699
|
+
inspect_isolated_set(stage, declaration, receipt)
|
|
700
|
+
if destination.exists() or destination.is_symlink():
|
|
701
|
+
raise RecoveryError("Recovery destination appeared during staging")
|
|
702
|
+
# Publish exclusively, even if another process races to create an
|
|
703
|
+
# empty destination directory between preflight and publication.
|
|
704
|
+
_exclusive_publish_directory(stage, destination)
|
|
705
|
+
return {
|
|
706
|
+
"setPath": str(destination),
|
|
707
|
+
"receipt": receipt,
|
|
708
|
+
"backupArtifactIntegrityVerified": True,
|
|
709
|
+
"applicationRecoveryReady": False,
|
|
710
|
+
"dynamicProtectorProofRequired": True,
|
|
711
|
+
"quiescenceGuardRuntimeId": before["runtimeId"],
|
|
712
|
+
"quiescenceGuardImageDigest": before["imageDigest"],
|
|
713
|
+
}
|
|
714
|
+
finally:
|
|
715
|
+
if stage.exists():
|
|
716
|
+
shutil.rmtree(stage)
|