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.
Files changed (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. 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
+ ]