patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/backup.py
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""Backup-manifest preparation for guarded change transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from patchshuttle.errors import (
|
|
16
|
+
ExecutionError,
|
|
17
|
+
ExecutionErrorCode,
|
|
18
|
+
PolicyError,
|
|
19
|
+
)
|
|
20
|
+
from patchshuttle.planner import FileDisposition, Plan, PlannedFileChange
|
|
21
|
+
from patchshuttle.policy import PathKind, Policy
|
|
22
|
+
from patchshuttle.workspace import Workspace
|
|
23
|
+
|
|
24
|
+
_MAX_MANIFEST_BYTES = 5_000_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackupStatus(str, Enum):
|
|
28
|
+
"""Lifecycle states written to a Phase 8 backup manifest."""
|
|
29
|
+
|
|
30
|
+
PREPARED = "PREPARED"
|
|
31
|
+
COMPLETED = "COMPLETED"
|
|
32
|
+
FAILED = "FAILED"
|
|
33
|
+
CHANGES_KEPT = "CHANGES_KEPT"
|
|
34
|
+
ROLLED_BACK = "ROLLED_BACK"
|
|
35
|
+
ROLLBACK_FAILED = "ROLLBACK_FAILED"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BackupEntryKind(str, Enum):
|
|
39
|
+
"""Filesystem kinds represented by a transaction manifest."""
|
|
40
|
+
|
|
41
|
+
FILE = "file"
|
|
42
|
+
DIRECTORY = "directory"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OriginalState(str, Enum):
|
|
46
|
+
"""Whether a transaction target existed before execution."""
|
|
47
|
+
|
|
48
|
+
ABSENT = "ABSENT"
|
|
49
|
+
PRESENT = "PRESENT"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class BackupEntry:
|
|
54
|
+
"""One immutable manifest record and optional original-file copy."""
|
|
55
|
+
|
|
56
|
+
path: PurePosixPath
|
|
57
|
+
kind: BackupEntryKind
|
|
58
|
+
original_state: OriginalState
|
|
59
|
+
backup_path: PurePosixPath | None = None
|
|
60
|
+
original_sha256: str | None = None
|
|
61
|
+
original_size: int | None = None
|
|
62
|
+
original_mode: int | None = None
|
|
63
|
+
encoding: str | None = None
|
|
64
|
+
newline: str | None = None
|
|
65
|
+
applied_sha256: str | None = None
|
|
66
|
+
applied_size: int | None = None
|
|
67
|
+
applied_mode: int | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class PreparedBackup:
|
|
72
|
+
"""One manifest directory created before project writes begin."""
|
|
73
|
+
|
|
74
|
+
plan: Plan
|
|
75
|
+
path: Path
|
|
76
|
+
run_timestamp: str
|
|
77
|
+
entries: tuple[BackupEntry, ...] = ()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def manifest_path(self) -> Path:
|
|
81
|
+
return self.path / "manifest.json"
|
|
82
|
+
|
|
83
|
+
def entry_for(self, path: PurePosixPath) -> BackupEntry:
|
|
84
|
+
"""Return the retained manifest entry for one planned path."""
|
|
85
|
+
|
|
86
|
+
for entry in self.entries:
|
|
87
|
+
if entry.path == path:
|
|
88
|
+
return entry
|
|
89
|
+
raise KeyError(path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class LoadedBackup:
|
|
94
|
+
"""A validated completed manifest reopened for manual rollback."""
|
|
95
|
+
|
|
96
|
+
workspace: Workspace = field(repr=False)
|
|
97
|
+
path: Path
|
|
98
|
+
job_id: str
|
|
99
|
+
job_hash: str
|
|
100
|
+
run_timestamp: str
|
|
101
|
+
entries: tuple[BackupEntry, ...]
|
|
102
|
+
payload: dict = field(repr=False)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def manifest_path(self) -> Path:
|
|
106
|
+
return self.path / "manifest.json"
|
|
107
|
+
|
|
108
|
+
def entry_for(self, path: PurePosixPath) -> BackupEntry:
|
|
109
|
+
for entry in self.entries:
|
|
110
|
+
if entry.path == path:
|
|
111
|
+
return entry
|
|
112
|
+
raise KeyError(path)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prepare_backup(plan: Plan) -> PreparedBackup:
|
|
116
|
+
"""Capture every original before marking a transaction backup prepared."""
|
|
117
|
+
|
|
118
|
+
timestamp = _run_timestamp()
|
|
119
|
+
root = plan.workspace.patches_dir / "backups"
|
|
120
|
+
job_root = root / plan.job.id
|
|
121
|
+
run_root = job_root / timestamp
|
|
122
|
+
try:
|
|
123
|
+
_require_internal_directory(plan.workspace.patches_dir)
|
|
124
|
+
_ensure_internal_directory(root)
|
|
125
|
+
_ensure_internal_directory(job_root)
|
|
126
|
+
run_root.mkdir()
|
|
127
|
+
except (OSError, ValueError) as exc:
|
|
128
|
+
raise ExecutionError(
|
|
129
|
+
ExecutionErrorCode.BACKUP_FAILED,
|
|
130
|
+
"backup directory could not be prepared",
|
|
131
|
+
path=_relative_display(plan.workspace.root, run_root),
|
|
132
|
+
) from exc
|
|
133
|
+
|
|
134
|
+
backup = PreparedBackup(plan=plan, path=run_root, run_timestamp=timestamp)
|
|
135
|
+
try:
|
|
136
|
+
backup = PreparedBackup(
|
|
137
|
+
plan=plan,
|
|
138
|
+
path=run_root,
|
|
139
|
+
run_timestamp=timestamp,
|
|
140
|
+
entries=_prepare_entries(backup),
|
|
141
|
+
)
|
|
142
|
+
update_backup(backup, BackupStatus.PREPARED)
|
|
143
|
+
except ExecutionError:
|
|
144
|
+
try:
|
|
145
|
+
run_root.rmdir()
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
raise
|
|
149
|
+
return backup
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def update_backup(
|
|
153
|
+
backup: PreparedBackup,
|
|
154
|
+
status: BackupStatus,
|
|
155
|
+
*,
|
|
156
|
+
failure_code: ExecutionErrorCode | None = None,
|
|
157
|
+
capture_applied_state: bool = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Atomically replace the manifest with a new lifecycle state."""
|
|
160
|
+
|
|
161
|
+
payload = _manifest_payload(
|
|
162
|
+
backup,
|
|
163
|
+
status,
|
|
164
|
+
failure_code=failure_code,
|
|
165
|
+
capture_applied_state=capture_applied_state,
|
|
166
|
+
)
|
|
167
|
+
_write_manifest_payload(
|
|
168
|
+
backup.manifest_path,
|
|
169
|
+
payload,
|
|
170
|
+
workspace_root=backup.plan.workspace.root,
|
|
171
|
+
backup_path=backup.path,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_completed_backup(
|
|
176
|
+
workspace: Workspace,
|
|
177
|
+
reference: str,
|
|
178
|
+
*,
|
|
179
|
+
job_id: str,
|
|
180
|
+
job_hash: str,
|
|
181
|
+
) -> LoadedBackup:
|
|
182
|
+
"""Open and validate a completed project-local backup manifest."""
|
|
183
|
+
|
|
184
|
+
backup_path = _resolve_backup_reference(
|
|
185
|
+
workspace,
|
|
186
|
+
reference,
|
|
187
|
+
job_id=job_id,
|
|
188
|
+
)
|
|
189
|
+
manifest_path = backup_path / "manifest.json"
|
|
190
|
+
try:
|
|
191
|
+
metadata = manifest_path.lstat()
|
|
192
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > _MAX_MANIFEST_BYTES:
|
|
193
|
+
raise OSError("manifest is not a bounded regular file")
|
|
194
|
+
raw = manifest_path.read_bytes()
|
|
195
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
196
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
197
|
+
raise ExecutionError(
|
|
198
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
199
|
+
"backup manifest could not be read",
|
|
200
|
+
path=_relative_display(workspace.root, manifest_path),
|
|
201
|
+
backup_path=backup_path,
|
|
202
|
+
rollback_succeeded=False,
|
|
203
|
+
) from exc
|
|
204
|
+
try:
|
|
205
|
+
entries = _parse_completed_manifest(
|
|
206
|
+
workspace,
|
|
207
|
+
payload,
|
|
208
|
+
job_id=job_id,
|
|
209
|
+
job_hash=job_hash,
|
|
210
|
+
)
|
|
211
|
+
timestamp = _required_manifest(payload, "run_timestamp", str)
|
|
212
|
+
except (TypeError, ValueError, PolicyError) as exc:
|
|
213
|
+
raise ExecutionError(
|
|
214
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
215
|
+
"backup manifest is invalid or is not a completed transaction",
|
|
216
|
+
path=_relative_display(workspace.root, manifest_path),
|
|
217
|
+
backup_path=backup_path,
|
|
218
|
+
rollback_succeeded=False,
|
|
219
|
+
) from exc
|
|
220
|
+
return LoadedBackup(
|
|
221
|
+
workspace=workspace,
|
|
222
|
+
path=backup_path,
|
|
223
|
+
job_id=job_id,
|
|
224
|
+
job_hash=job_hash,
|
|
225
|
+
run_timestamp=timestamp,
|
|
226
|
+
entries=entries,
|
|
227
|
+
payload=payload,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def update_loaded_backup(
|
|
232
|
+
backup: LoadedBackup,
|
|
233
|
+
status: BackupStatus,
|
|
234
|
+
*,
|
|
235
|
+
failure_code: ExecutionErrorCode | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Update lifecycle fields on a validated reopened manifest."""
|
|
238
|
+
|
|
239
|
+
payload = dict(backup.payload)
|
|
240
|
+
payload["status"] = status.value
|
|
241
|
+
payload["failure_code"] = failure_code.value if failure_code is not None else None
|
|
242
|
+
_write_manifest_payload(
|
|
243
|
+
backup.manifest_path,
|
|
244
|
+
payload,
|
|
245
|
+
workspace_root=backup.workspace.root,
|
|
246
|
+
backup_path=backup.path,
|
|
247
|
+
error_code=ExecutionErrorCode.ROLLBACK_FAILED,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _write_manifest_payload(
|
|
252
|
+
manifest_path: Path,
|
|
253
|
+
payload: dict,
|
|
254
|
+
*,
|
|
255
|
+
workspace_root: Path,
|
|
256
|
+
backup_path: Path,
|
|
257
|
+
error_code: ExecutionErrorCode = ExecutionErrorCode.BACKUP_FAILED,
|
|
258
|
+
) -> None:
|
|
259
|
+
temporary = backup_path / f".manifest-{uuid.uuid4().hex}.tmp"
|
|
260
|
+
raw = (
|
|
261
|
+
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
262
|
+
).encode("utf-8")
|
|
263
|
+
try:
|
|
264
|
+
descriptor = os.open(
|
|
265
|
+
temporary,
|
|
266
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
267
|
+
0o600,
|
|
268
|
+
)
|
|
269
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
270
|
+
stream.write(raw)
|
|
271
|
+
stream.flush()
|
|
272
|
+
os.fsync(stream.fileno())
|
|
273
|
+
os.replace(temporary, manifest_path)
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
raise ExecutionError(
|
|
276
|
+
error_code,
|
|
277
|
+
"backup manifest could not be written",
|
|
278
|
+
path=_relative_display(workspace_root, manifest_path),
|
|
279
|
+
backup_path=backup_path,
|
|
280
|
+
rollback_succeeded=(
|
|
281
|
+
False if error_code is ExecutionErrorCode.ROLLBACK_FAILED else None
|
|
282
|
+
),
|
|
283
|
+
) from exc
|
|
284
|
+
finally:
|
|
285
|
+
try:
|
|
286
|
+
temporary.unlink()
|
|
287
|
+
except OSError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _manifest_payload(
|
|
292
|
+
backup: PreparedBackup,
|
|
293
|
+
status: BackupStatus,
|
|
294
|
+
*,
|
|
295
|
+
failure_code: ExecutionErrorCode | None,
|
|
296
|
+
capture_applied_state: bool = False,
|
|
297
|
+
) -> dict:
|
|
298
|
+
plan = backup.plan
|
|
299
|
+
applied = (
|
|
300
|
+
_capture_applied_states(backup)
|
|
301
|
+
if status is BackupStatus.COMPLETED and capture_applied_state
|
|
302
|
+
else {}
|
|
303
|
+
)
|
|
304
|
+
payload = {
|
|
305
|
+
"manifest_version": 1,
|
|
306
|
+
"project_id": plan.job.project_id,
|
|
307
|
+
"job_id": plan.job.id,
|
|
308
|
+
"job_hash": plan.job_hash,
|
|
309
|
+
"run_timestamp": backup.run_timestamp,
|
|
310
|
+
"status": status.value,
|
|
311
|
+
"failure_code": failure_code.value if failure_code is not None else None,
|
|
312
|
+
"action_order": [f"{action.id}:{action.name}" for action in plan.actions],
|
|
313
|
+
"formatting_targets": [path.as_posix() for path in plan.formatting_targets],
|
|
314
|
+
"entries": [_entry_payload(entry) for entry in backup.entries],
|
|
315
|
+
}
|
|
316
|
+
if applied:
|
|
317
|
+
payload["applied_states"] = {
|
|
318
|
+
path.as_posix(): value for path, value in applied.items()
|
|
319
|
+
}
|
|
320
|
+
return payload
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _prepare_entries(backup: PreparedBackup) -> tuple[BackupEntry, ...]:
|
|
324
|
+
entries = [
|
|
325
|
+
BackupEntry(
|
|
326
|
+
path=path,
|
|
327
|
+
kind=BackupEntryKind.DIRECTORY,
|
|
328
|
+
original_state=OriginalState.ABSENT,
|
|
329
|
+
)
|
|
330
|
+
for path in backup.plan.directories_to_create
|
|
331
|
+
]
|
|
332
|
+
for change in backup.plan.file_changes:
|
|
333
|
+
if change.disposition is FileDisposition.CREATE:
|
|
334
|
+
entries.append(
|
|
335
|
+
BackupEntry(
|
|
336
|
+
path=change.path,
|
|
337
|
+
kind=BackupEntryKind.FILE,
|
|
338
|
+
original_state=OriginalState.ABSENT,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
entries.append(_capture_original(backup, change))
|
|
343
|
+
return tuple(entries)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _capture_original(
|
|
347
|
+
backup: PreparedBackup,
|
|
348
|
+
change: PlannedFileChange,
|
|
349
|
+
) -> BackupEntry:
|
|
350
|
+
if change.before_sha256 is None or change.before_size is None:
|
|
351
|
+
raise ExecutionError(
|
|
352
|
+
ExecutionErrorCode.BACKUP_FAILED,
|
|
353
|
+
"modified-file plan is missing its original fingerprint",
|
|
354
|
+
path=change.path.as_posix(),
|
|
355
|
+
backup_path=backup.path,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
policy = Policy(backup.plan.workspace)
|
|
359
|
+
try:
|
|
360
|
+
target = policy.resolve(change.path, allow_missing=True)
|
|
361
|
+
if target.kind is not PathKind.FILE:
|
|
362
|
+
raise ExecutionError(
|
|
363
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
364
|
+
"modified file no longer has the planned type",
|
|
365
|
+
path=change.path.as_posix(),
|
|
366
|
+
backup_path=backup.path,
|
|
367
|
+
)
|
|
368
|
+
before_metadata = target.absolute.lstat()
|
|
369
|
+
raw = target.absolute.read_bytes()
|
|
370
|
+
after_metadata = target.absolute.lstat()
|
|
371
|
+
revalidated = policy.resolve(change.path, allow_missing=True)
|
|
372
|
+
except ExecutionError:
|
|
373
|
+
raise
|
|
374
|
+
except (OSError, PolicyError) as exc:
|
|
375
|
+
raise ExecutionError(
|
|
376
|
+
ExecutionErrorCode.BACKUP_FAILED,
|
|
377
|
+
"original file could not be captured",
|
|
378
|
+
path=change.path.as_posix(),
|
|
379
|
+
backup_path=backup.path,
|
|
380
|
+
) from exc
|
|
381
|
+
|
|
382
|
+
metadata_changed = _metadata_identity(before_metadata) != _metadata_identity(
|
|
383
|
+
after_metadata
|
|
384
|
+
)
|
|
385
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
386
|
+
if (
|
|
387
|
+
revalidated.kind is not PathKind.FILE
|
|
388
|
+
or revalidated.absolute != target.absolute
|
|
389
|
+
or metadata_changed
|
|
390
|
+
or len(raw) != change.before_size
|
|
391
|
+
or digest != change.before_sha256
|
|
392
|
+
):
|
|
393
|
+
raise ExecutionError(
|
|
394
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
395
|
+
"original file changed before backup capture completed",
|
|
396
|
+
path=change.path.as_posix(),
|
|
397
|
+
backup_path=backup.path,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
backup_path = PurePosixPath("originals", *change.path.parts)
|
|
401
|
+
_write_original(backup, backup_path, raw)
|
|
402
|
+
return BackupEntry(
|
|
403
|
+
path=change.path,
|
|
404
|
+
kind=BackupEntryKind.FILE,
|
|
405
|
+
original_state=OriginalState.PRESENT,
|
|
406
|
+
backup_path=backup_path,
|
|
407
|
+
original_sha256=digest,
|
|
408
|
+
original_size=len(raw),
|
|
409
|
+
original_mode=stat.S_IMODE(after_metadata.st_mode),
|
|
410
|
+
encoding=change.encoding,
|
|
411
|
+
newline=change.newline.value,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _write_original(
|
|
416
|
+
backup: PreparedBackup,
|
|
417
|
+
relative: PurePosixPath,
|
|
418
|
+
raw: bytes,
|
|
419
|
+
) -> None:
|
|
420
|
+
target = backup.path.joinpath(*relative.parts)
|
|
421
|
+
try:
|
|
422
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
descriptor = os.open(
|
|
424
|
+
target,
|
|
425
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
426
|
+
0o600,
|
|
427
|
+
)
|
|
428
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
429
|
+
stream.write(raw)
|
|
430
|
+
stream.flush()
|
|
431
|
+
os.fsync(stream.fileno())
|
|
432
|
+
except OSError as exc:
|
|
433
|
+
try:
|
|
434
|
+
target.unlink()
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
raise ExecutionError(
|
|
438
|
+
ExecutionErrorCode.BACKUP_FAILED,
|
|
439
|
+
"original file copy could not be written",
|
|
440
|
+
path=relative.as_posix(),
|
|
441
|
+
backup_path=backup.path,
|
|
442
|
+
) from exc
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _entry_payload(
|
|
446
|
+
entry: BackupEntry,
|
|
447
|
+
) -> dict:
|
|
448
|
+
payload = {
|
|
449
|
+
"path": entry.path.as_posix(),
|
|
450
|
+
"kind": entry.kind.value,
|
|
451
|
+
"original_state": entry.original_state.value,
|
|
452
|
+
}
|
|
453
|
+
if entry.original_state is OriginalState.PRESENT:
|
|
454
|
+
payload.update(
|
|
455
|
+
{
|
|
456
|
+
"backup_path": (
|
|
457
|
+
entry.backup_path.as_posix()
|
|
458
|
+
if entry.backup_path is not None
|
|
459
|
+
else None
|
|
460
|
+
),
|
|
461
|
+
"original_sha256": entry.original_sha256,
|
|
462
|
+
"original_size": entry.original_size,
|
|
463
|
+
"original_mode": entry.original_mode,
|
|
464
|
+
"encoding": entry.encoding,
|
|
465
|
+
"newline": entry.newline,
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
return payload
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _capture_applied_states(
|
|
472
|
+
backup: PreparedBackup,
|
|
473
|
+
) -> dict[PurePosixPath, dict[str, object]]:
|
|
474
|
+
policy = Policy(backup.plan.workspace)
|
|
475
|
+
states: dict[PurePosixPath, dict[str, object]] = {}
|
|
476
|
+
for entry in backup.entries:
|
|
477
|
+
try:
|
|
478
|
+
target = policy.resolve(entry.path)
|
|
479
|
+
metadata = target.absolute.lstat()
|
|
480
|
+
if entry.kind is BackupEntryKind.FILE:
|
|
481
|
+
if target.kind is not PathKind.FILE:
|
|
482
|
+
raise OSError("completed file has the wrong type")
|
|
483
|
+
raw = target.absolute.read_bytes()
|
|
484
|
+
revalidated = policy.resolve(entry.path)
|
|
485
|
+
if (
|
|
486
|
+
revalidated.kind is not PathKind.FILE
|
|
487
|
+
or revalidated.absolute != target.absolute
|
|
488
|
+
or len(raw) != metadata.st_size
|
|
489
|
+
):
|
|
490
|
+
raise OSError("completed file changed during capture")
|
|
491
|
+
states[entry.path] = {
|
|
492
|
+
"kind": entry.kind.value,
|
|
493
|
+
"mode": stat.S_IMODE(metadata.st_mode),
|
|
494
|
+
"sha256": hashlib.sha256(raw).hexdigest(),
|
|
495
|
+
"size": len(raw),
|
|
496
|
+
}
|
|
497
|
+
else:
|
|
498
|
+
if target.kind is not PathKind.DIRECTORY:
|
|
499
|
+
raise OSError("completed directory has the wrong type")
|
|
500
|
+
states[entry.path] = {
|
|
501
|
+
"kind": entry.kind.value,
|
|
502
|
+
"mode": stat.S_IMODE(metadata.st_mode),
|
|
503
|
+
"sha256": None,
|
|
504
|
+
"size": 0,
|
|
505
|
+
}
|
|
506
|
+
except (OSError, PolicyError) as exc:
|
|
507
|
+
raise ExecutionError(
|
|
508
|
+
ExecutionErrorCode.BACKUP_FAILED,
|
|
509
|
+
"completed transaction state could not be retained",
|
|
510
|
+
path=entry.path.as_posix(),
|
|
511
|
+
backup_path=backup.path,
|
|
512
|
+
) from exc
|
|
513
|
+
return states
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _resolve_backup_reference(
|
|
517
|
+
workspace: Workspace,
|
|
518
|
+
reference: str,
|
|
519
|
+
*,
|
|
520
|
+
job_id: str,
|
|
521
|
+
) -> Path:
|
|
522
|
+
relative = PurePosixPath(reference)
|
|
523
|
+
if (
|
|
524
|
+
relative.is_absolute()
|
|
525
|
+
or "\\" in reference
|
|
526
|
+
or len(relative.parts) != 4
|
|
527
|
+
or relative.parts[:3] != ("patches", "backups", job_id)
|
|
528
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
529
|
+
):
|
|
530
|
+
raise ExecutionError(
|
|
531
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
532
|
+
"registry backup reference is invalid",
|
|
533
|
+
path=reference,
|
|
534
|
+
rollback_succeeded=False,
|
|
535
|
+
)
|
|
536
|
+
target = workspace.root.joinpath(*relative.parts)
|
|
537
|
+
current = workspace.root
|
|
538
|
+
try:
|
|
539
|
+
for part in relative.parts:
|
|
540
|
+
current = current / part
|
|
541
|
+
metadata = current.lstat()
|
|
542
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
|
543
|
+
raise OSError("backup component is not a real directory")
|
|
544
|
+
if target.resolve() != target.absolute():
|
|
545
|
+
raise OSError("backup path resolves through an alias")
|
|
546
|
+
except OSError as exc:
|
|
547
|
+
raise ExecutionError(
|
|
548
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
549
|
+
"backup reference is missing or unsafe",
|
|
550
|
+
path=reference,
|
|
551
|
+
backup_path=target,
|
|
552
|
+
rollback_succeeded=False,
|
|
553
|
+
) from exc
|
|
554
|
+
return target
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _parse_completed_manifest(
|
|
558
|
+
workspace: Workspace,
|
|
559
|
+
payload: object,
|
|
560
|
+
*,
|
|
561
|
+
job_id: str,
|
|
562
|
+
job_hash: str,
|
|
563
|
+
) -> tuple[BackupEntry, ...]:
|
|
564
|
+
if not isinstance(payload, dict):
|
|
565
|
+
raise TypeError("manifest root must be an object")
|
|
566
|
+
if _required_manifest(payload, "manifest_version", int) != 1:
|
|
567
|
+
raise ValueError("manual rollback requires a version 1 manifest")
|
|
568
|
+
if _required_manifest(payload, "project_id", str) != workspace.project_id:
|
|
569
|
+
raise ValueError("manifest project ID does not match")
|
|
570
|
+
if _required_manifest(payload, "job_id", str) != job_id:
|
|
571
|
+
raise ValueError("manifest job ID does not match")
|
|
572
|
+
if _required_manifest(payload, "job_hash", str) != job_hash:
|
|
573
|
+
raise ValueError("manifest job hash does not match")
|
|
574
|
+
if _required_manifest(payload, "status", str) != BackupStatus.COMPLETED.value:
|
|
575
|
+
raise ValueError("manifest is not completed")
|
|
576
|
+
raw_entries = payload.get("entries")
|
|
577
|
+
applied_states = payload.get("applied_states")
|
|
578
|
+
if not isinstance(raw_entries, list):
|
|
579
|
+
raise TypeError("manifest entries must be an array")
|
|
580
|
+
if not isinstance(applied_states, dict):
|
|
581
|
+
raise TypeError("manifest is missing completed applied states")
|
|
582
|
+
if len(raw_entries) > workspace.config.execution.max_inventory_entries:
|
|
583
|
+
raise ValueError("manifest has too many entries")
|
|
584
|
+
|
|
585
|
+
policy = Policy(workspace)
|
|
586
|
+
entries: list[BackupEntry] = []
|
|
587
|
+
seen: set[PurePosixPath] = set()
|
|
588
|
+
for raw_entry in raw_entries:
|
|
589
|
+
if not isinstance(raw_entry, dict):
|
|
590
|
+
raise TypeError("manifest entry must be an object")
|
|
591
|
+
path = policy.normalize(_required_manifest(raw_entry, "path", str))
|
|
592
|
+
if not path.parts or policy.is_protected(path) or path in seen:
|
|
593
|
+
raise ValueError("manifest entry path is invalid")
|
|
594
|
+
seen.add(path)
|
|
595
|
+
kind = BackupEntryKind(_required_manifest(raw_entry, "kind", str))
|
|
596
|
+
original = OriginalState(_required_manifest(raw_entry, "original_state", str))
|
|
597
|
+
if kind is BackupEntryKind.DIRECTORY and original is not OriginalState.ABSENT:
|
|
598
|
+
raise ValueError("directory backup entry has an invalid original state")
|
|
599
|
+
applied = applied_states.get(path.as_posix())
|
|
600
|
+
if not isinstance(applied, dict):
|
|
601
|
+
raise TypeError("manifest entry is missing its applied state")
|
|
602
|
+
if _required_manifest(applied, "kind", str) != kind.value:
|
|
603
|
+
raise ValueError("applied entry kind does not match")
|
|
604
|
+
applied_mode = _required_manifest(applied, "mode", int)
|
|
605
|
+
applied_size = _required_manifest(applied, "size", int)
|
|
606
|
+
applied_sha = applied.get("sha256")
|
|
607
|
+
if kind is BackupEntryKind.FILE:
|
|
608
|
+
if not isinstance(applied_sha, str) or applied_size < 0:
|
|
609
|
+
raise TypeError("applied file fingerprint is invalid")
|
|
610
|
+
elif applied_sha is not None or applied_size != 0:
|
|
611
|
+
raise ValueError("applied directory fingerprint is invalid")
|
|
612
|
+
|
|
613
|
+
backup_relative: PurePosixPath | None = None
|
|
614
|
+
original_sha: str | None = None
|
|
615
|
+
original_size: int | None = None
|
|
616
|
+
original_mode: int | None = None
|
|
617
|
+
encoding: str | None = None
|
|
618
|
+
newline: str | None = None
|
|
619
|
+
if original is OriginalState.PRESENT:
|
|
620
|
+
backup_relative = _safe_backup_copy_path(
|
|
621
|
+
_required_manifest(raw_entry, "backup_path", str)
|
|
622
|
+
)
|
|
623
|
+
original_sha = _required_manifest(raw_entry, "original_sha256", str)
|
|
624
|
+
original_size = _required_manifest(raw_entry, "original_size", int)
|
|
625
|
+
original_mode = _required_manifest(raw_entry, "original_mode", int)
|
|
626
|
+
encoding = _required_manifest(raw_entry, "encoding", str)
|
|
627
|
+
newline = _required_manifest(raw_entry, "newline", str)
|
|
628
|
+
entries.append(
|
|
629
|
+
BackupEntry(
|
|
630
|
+
path=path,
|
|
631
|
+
kind=kind,
|
|
632
|
+
original_state=original,
|
|
633
|
+
backup_path=backup_relative,
|
|
634
|
+
original_sha256=original_sha,
|
|
635
|
+
original_size=original_size,
|
|
636
|
+
original_mode=original_mode,
|
|
637
|
+
encoding=encoding,
|
|
638
|
+
newline=newline,
|
|
639
|
+
applied_sha256=applied_sha,
|
|
640
|
+
applied_size=applied_size,
|
|
641
|
+
applied_mode=applied_mode,
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
return tuple(entries)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _safe_backup_copy_path(value: str) -> PurePosixPath:
|
|
648
|
+
path = PurePosixPath(value)
|
|
649
|
+
if (
|
|
650
|
+
path.is_absolute()
|
|
651
|
+
or not path.parts
|
|
652
|
+
or path.parts[0] != "originals"
|
|
653
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
654
|
+
or "\\" in value
|
|
655
|
+
):
|
|
656
|
+
raise ValueError("original backup path is invalid")
|
|
657
|
+
return path
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _required_manifest(payload: dict, key: str, expected: type):
|
|
661
|
+
value = payload.get(key)
|
|
662
|
+
if type(value) is not expected:
|
|
663
|
+
raise TypeError(f"manifest field {key} has an invalid type")
|
|
664
|
+
return value
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _metadata_identity(metadata: os.stat_result) -> tuple[int, int, int, int, int]:
|
|
668
|
+
return (
|
|
669
|
+
metadata.st_dev,
|
|
670
|
+
metadata.st_ino,
|
|
671
|
+
metadata.st_size,
|
|
672
|
+
metadata.st_mtime_ns,
|
|
673
|
+
metadata.st_mode,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _require_internal_directory(path: Path) -> None:
|
|
678
|
+
metadata = path.lstat()
|
|
679
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
|
680
|
+
raise ValueError(f"internal path is not a real directory: {path}")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _ensure_internal_directory(path: Path) -> None:
|
|
684
|
+
try:
|
|
685
|
+
path.mkdir()
|
|
686
|
+
except FileExistsError:
|
|
687
|
+
_require_internal_directory(path)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _relative_display(root: Path, path: Path) -> str:
|
|
691
|
+
try:
|
|
692
|
+
return path.relative_to(root).as_posix()
|
|
693
|
+
except ValueError:
|
|
694
|
+
return str(path)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _run_timestamp() -> str:
|
|
698
|
+
return datetime.now().astimezone().strftime("%Y_%m_%d_%H%M%S_%f")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
__all__ = [
|
|
702
|
+
"BackupEntry",
|
|
703
|
+
"BackupEntryKind",
|
|
704
|
+
"BackupStatus",
|
|
705
|
+
"LoadedBackup",
|
|
706
|
+
"OriginalState",
|
|
707
|
+
"PreparedBackup",
|
|
708
|
+
"load_completed_backup",
|
|
709
|
+
"prepare_backup",
|
|
710
|
+
"update_backup",
|
|
711
|
+
"update_loaded_backup",
|
|
712
|
+
]
|