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
@@ -0,0 +1,163 @@
1
+ # PatchShuttle AI Guide
2
+
3
+ This file is the provider-neutral contract for ChatGPT and other AI services
4
+ that prepare PatchShuttle jobs for this project.
5
+
6
+ ## Project identity
7
+
8
+ - Protocol: `1`
9
+ - Project ID: `{{PROJECT_ID}}`
10
+ - Canonical job extension: `.psh.yaml`
11
+
12
+ Every job must use exactly this project ID. Do not copy an ID from another
13
+ project or invent a replacement.
14
+
15
+ ## Required AI response
16
+
17
+ When asked for a PatchShuttle job:
18
+
19
+ 1. Return exactly one UTF-8 YAML document.
20
+ 2. Do not wrap it in Markdown fences.
21
+ 3. Do not add prose before or after the YAML.
22
+ 4. Keep the job focused on one reviewable goal.
23
+ 5. Use only the actions and checks listed below.
24
+ 6. Never provide shell commands, Python code to execute the job, or policy
25
+ overrides.
26
+ 7. Preserve the same job ID when correcting a rejected job only when the user
27
+ explicitly requests a correction of that same attempt.
28
+ 8. Never add confirmation or rollback choices to YAML. `--yes` and
29
+ `--keep-changes` are user-controlled CLI decisions, not job fields.
30
+
31
+ ## Job kinds
32
+
33
+ - `audit` - one or more read-only audit actions, no checks.
34
+ - `patch` - one or more change actions and appropriate checks.
35
+ - `verify` - one or more checks, no actions.
36
+
37
+ ## Audit actions
38
+
39
+ - `tree` - bounded directory tree.
40
+ - `read` - bounded text-file excerpt.
41
+ - `search` - literal text search.
42
+ - `find_files` - bounded glob search.
43
+ - `file_info` - file metadata.
44
+ - `hash` - SHA-256 file hash.
45
+ - `git_status` - Git status when Git is available.
46
+ - `environment` - bounded development-environment summary.
47
+
48
+ ## Change actions
49
+
50
+ - `create_directory` - create one directory.
51
+ - `create_file` - create one new text file.
52
+ - `replace_exact` - replace an exact text occurrence count.
53
+ - `insert_before` - insert text before an exact anchor.
54
+ - `insert_after` - insert text after an exact anchor.
55
+ - `delete_exact` - delete exact text with an expected count.
56
+ - `apply_diff` - apply a unified diff to existing text files.
57
+
58
+ ## Checks
59
+
60
+ - `compileall`
61
+ - `pytest` - optional arguments are limited to `-q`, `--quiet`, `-v`,
62
+ `--verbose`, `-x`, `--exitfirst`, `-s`, `--disable-warnings`,
63
+ `--strict-config`, `--strict-markers`, positive `--maxfail=N`,
64
+ `--tb=auto|long|short|line|native|no`, and
65
+ `--capture=fd|sys|no|tee-sys`.
66
+ - `unittest`
67
+ - `django_check`
68
+ - `django_migrations_check`
69
+ - `django_test` - labels must be dotted Python identifiers.
70
+ - `import_check`
71
+ - `profile` - only a profile already defined by the user in
72
+ `patches/patchshuttle.toml`.
73
+
74
+ ## Audit example
75
+
76
+ ```yaml
77
+ protocol: 1
78
+ project_id: {{PROJECT_ID}}
79
+ id: AUDIT-001
80
+ kind: audit
81
+ title: Inspect the project structure
82
+ actions:
83
+ - tree:
84
+ path: .
85
+ depth: 4
86
+ - git_status: {}
87
+ ```
88
+
89
+ ## Patch example
90
+
91
+ ```yaml
92
+ protocol: 1
93
+ project_id: {{PROJECT_ID}}
94
+ id: PATCH-001
95
+ kind: patch
96
+ title: Create a small Python module
97
+ actions:
98
+ - create_directory:
99
+ path: src/example
100
+ - create_file:
101
+ path: src/example/__init__.py
102
+ content: |
103
+ VALUE = 1
104
+ checks:
105
+ - compileall:
106
+ paths: [src]
107
+ ```
108
+
109
+ ## Verify example
110
+
111
+ ```yaml
112
+ protocol: 1
113
+ project_id: {{PROJECT_ID}}
114
+ id: VERIFY-001
115
+ kind: verify
116
+ title: Run the project tests without changing files
117
+ checks:
118
+ - pytest:
119
+ paths: [tests]
120
+ args: [-q]
121
+ ```
122
+
123
+ ## Local workflow
124
+
125
+ The user saves the YAML in `patches/inbox/`, validates it, runs
126
+ `patchshuttle plan`, and reviews the read-only plan locally. An audit can run
127
+ without confirmation. Patch and verify jobs require explicit confirmation or
128
+ deliberate `--yes` automation. If validation or planning fails, ask for the
129
+ exact terminal result. After every recorded attempt, ask the user for the
130
+ generated `.log` file or a fresh `patchshuttle handoff` file and use its final
131
+ `PATCHSHUTTLE_AI_HANDOFF` block before preparing the next job. Never state that
132
+ a job was applied merely because YAML or a successful plan was created.
133
+
134
+ Interpret failure states conservatively:
135
+
136
+ - `ROLLED_BACK` means declared transaction paths were restored, but reported
137
+ external check side effects still require review.
138
+ - `SKIPPED_CHANGES_KEPT` means the user explicitly retained partial declared
139
+ changes. Request a fresh audit or handoff before producing a corrective job.
140
+ - `SKIPPED_NO_CHANGES` means rollback was skipped before a declared change was
141
+ published; still use the log as the authoritative result.
142
+ - `ROLLBACK_FAILED` means restoration is incomplete. Stop proposing new
143
+ changes until the user resolves the listed paths.
144
+
145
+ This `0.1.0a2` build provides workspace initialization, validation, read-only
146
+ planning, bounded audit execution, approved patch execution, and approved
147
+ one-pass verification. Patch execution uses all text change actions,
148
+ controlled initial checks, changed-Python-only isort then Black, final checks,
149
+ bounded before/after workspace inventory, undeclared side-effect reporting,
150
+ rollback of declared transaction paths, exact CLI job archives, an atomic
151
+ registry, and fixed-section redacted logs. Completed patches also support
152
+ guarded manual rollback. An explicitly user-approved `--keep-changes` run can
153
+ retain partial declared changes after failure and records that decision
154
+ distinctly. Snapshot and handoff commands produce bounded context without
155
+ dumping source contents. The same completed job ID and normalized hash returns
156
+ `ALREADY_APPLIED`; the same ID with different content returns
157
+ `PATCH_ID_CONFLICT`. An `UNEXPECTED_WORKSPACE_CHANGE` result means the reported
158
+ external path must be reviewed separately; for a normal patch, declared paths
159
+ are rolled back. Redaction is best-effort; do not tell the user that a log is
160
+ guaranteed to contain no secrets.
161
+
162
+ See `PATCHSHUTTLE_PROTOCOL.md` and `patchshuttle.schema.json` for the detailed
163
+ syntax.
@@ -0,0 +1,10 @@
1
+ protocol: 1
2
+ project_id: {{PROJECT_ID}}
3
+ id: AUDIT-EXAMPLE
4
+ kind: audit
5
+ title: Inspect the initial project structure
6
+ actions:
7
+ - tree:
8
+ path: .
9
+ depth: 4
10
+ - environment: {}
@@ -0,0 +1,17 @@
1
+ protocol: 1
2
+ project_id: {{PROJECT_ID}}
3
+ id: PATCH-EXAMPLE
4
+ kind: patch
5
+ title: Create a small Python package
6
+ actions:
7
+ - create_directory:
8
+ path: src/example
9
+ - create_file:
10
+ path: src/example/__init__.py
11
+ content: |
12
+ """Example package created through a PatchShuttle job."""
13
+
14
+ VALUE = 1
15
+ checks:
16
+ - compileall:
17
+ paths: [src]
@@ -0,0 +1,109 @@
1
+ # PatchShuttle Protocol 1
2
+
3
+ Project ID for this workspace: `{{PROJECT_ID}}`
4
+
5
+ PatchShuttle jobs are single UTF-8 YAML documents whose filenames end exactly
6
+ with `.psh.yaml`. Unknown fields, duplicate mapping keys, custom YAML tags,
7
+ anchors, and aliases are rejected.
8
+
9
+ ## Top-level fields
10
+
11
+ - `protocol` - required integer `1`.
12
+ - `project_id` - required workspace identifier.
13
+ - `id` - required stable job identifier matching
14
+ `[A-Z][A-Z0-9_-]{2,63}`.
15
+ - `kind` - `audit`, `patch`, or `verify`.
16
+ - `title` - optional text.
17
+ - `description` - optional text.
18
+ - `actions` - action list.
19
+ - `checks` - check list.
20
+
21
+ An `audit` job requires audit actions and forbids checks. A `patch` job requires
22
+ change actions. A `verify` job requires checks and forbids actions.
23
+
24
+ Each action or check list entry is a mapping containing exactly one operation
25
+ name.
26
+
27
+ ## Audit actions
28
+
29
+ - `tree`: `path`, `depth`, `max_entries`, `include_hidden`.
30
+ - `read`: `path`, `start_line`, `end_line`, `max_bytes`.
31
+ - `search`: `path`, `text`, `glob`, `case_sensitive`, `max_results`.
32
+ - `find_files`: `path`, `glob`, `max_results`.
33
+ - `file_info`: `path`.
34
+ - `hash`: `path`, optional `algorithm: sha256`.
35
+ - `git_status`: empty mapping.
36
+ - `environment`: empty mapping.
37
+
38
+ ## Change actions
39
+
40
+ - `create_directory`: `path`.
41
+ - `create_file`: `path`, `content`, optional `encoding` and `newline`.
42
+ - `replace_exact`: `path`, `old`, `new`, `expected_count`.
43
+ - `insert_before`: `path`, `anchor`, `content`, `expected_count`.
44
+ - `insert_after`: `path`, `anchor`, `content`, `expected_count`.
45
+ - `delete_exact`: `path`, `text`, `expected_count`.
46
+ - `apply_diff`: `diff`, optional `strip` from `0` through `2`.
47
+
48
+ ## Checks
49
+
50
+ - `compileall`: non-empty `paths`, optional `quiet` from `0` through `2`.
51
+ - `pytest`: optional `paths`, `args`, and `timeout_seconds`. Allowed arguments:
52
+ `-q`, `--quiet`, `-v`, `--verbose`, `-x`, `--exitfirst`, `-s`,
53
+ `--disable-warnings`, `--strict-config`, `--strict-markers`, positive
54
+ `--maxfail=N`, `--tb=auto|long|short|line|native|no`, and
55
+ `--capture=fd|sys|no|tee-sys`.
56
+ - `unittest`: `discover` and `pattern`.
57
+ - `django_check`: `manage_py`.
58
+ - `django_migrations_check`: `manage_py`.
59
+ - `django_test`: `manage_py` and optional dotted-identifier `labels`.
60
+ - `import_check`: non-empty dotted Python `modules`.
61
+ - `profile`: `name` of a local profile already defined in
62
+ `patches/patchshuttle.toml`.
63
+
64
+ ## Local authority and safety
65
+
66
+ Jobs cannot change project identity, protected paths, confirmation, backups,
67
+ rollback, formatting order, command allowlists, size limits, or shell policy.
68
+ There is no arbitrary-shell action. Project checks can execute project code with
69
+ the current user's permissions, so PatchShuttle is not an operating-system
70
+ sandbox.
71
+
72
+ All job paths are relative to the workspace root. The implemented planner
73
+ rejects absolute paths, parent traversal, protected or ignored targets,
74
+ symbolic-link escapes, binary targets, special files, and files outside
75
+ configured limits.
76
+
77
+ Use `patchshuttle validate patches/inbox/JOB.psh.yaml`, then review
78
+ `patchshuttle plan patches/inbox/JOB.psh.yaml`. Planning is read-only and does
79
+ not mean the job was applied. `patchshuttle run JOB.psh.yaml` accepts every job
80
+ kind. `patchshuttle audit JOB.psh.yaml` requires an audit job and does not
81
+ prompt. `patchshuttle verify JOB.psh.yaml` requires a verify job and, like a
82
+ patch, uses a deny-by-default confirmation unless `--yes` is supplied. The
83
+ public Python equivalent is `execute_plan(plan, approved=True)`; audit plans do
84
+ not require approval. `patchshuttle run PATCH.psh.yaml --keep-changes` is a
85
+ user-controlled patch-only mode that requires separate confirmation unless
86
+ combined with `--yes`. It is not a YAML field and local policy may forbid it.
87
+
88
+ Audit output and traversal are bounded and a workspace comparison verifies
89
+ read-only behavior. Verify jobs run their checks once and record workspace side
90
+ effects. Patch execution includes controlled initial checks, scoped isort then
91
+ Black, repeated final checks, bounded before/after workspace inventory, and
92
+ automatic rollback. Final changes outside declared transaction paths and
93
+ configured ignored paths are reported as `UNEXPECTED_WORKSPACE_CHANGE`.
94
+ Declared patch paths are rolled back; reported external check side effects are
95
+ not removed automatically. If the user explicitly accepts `--keep-changes`, a
96
+ failed patch retains published declared changes and records
97
+ `SKIPPED_CHANGES_KEPT`; if no declared change was published, it records
98
+ `SKIPPED_NO_CHANGES`.
99
+
100
+ Every recorded job attempt writes a timestamped fixed-section log, archives
101
+ the exact CLI job in `applied/` or `failed/`, and atomically updates the
102
+ registry. The same completed ID and hash returns `ALREADY_APPLIED`; different
103
+ normalized content under an existing ID returns `PATCH_ID_CONFLICT`. Use
104
+ `patchshuttle rollback PATCH_ID` for guarded manual restoration of a completed
105
+ patch, `snapshot` for bounded project metadata, `handoff` for upload-friendly
106
+ AI context, `logs --last` for the newest log, and `status [JOB_ID]` for registry
107
+ state. Log redaction is best-effort, so review a log before sharing it. The JSON
108
+ Schema in `patchshuttle.schema.json` is the exact structural schema produced by
109
+ the installed model version.
@@ -0,0 +1 @@
1
+ """Packaged templates copied into initialized PatchShuttle workspaces."""
@@ -0,0 +1,306 @@
1
+ """Conservative rollback for paths created by one transaction attempt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import stat
7
+ from dataclasses import dataclass
8
+ from pathlib import PurePosixPath
9
+
10
+ import patchshuttle.actions as actions
11
+ from patchshuttle.backup import (
12
+ BackupEntryKind,
13
+ LoadedBackup,
14
+ OriginalState,
15
+ PreparedBackup,
16
+ )
17
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode, PolicyError
18
+ from patchshuttle.policy import PathKind, Policy
19
+ from patchshuttle.workspace import Workspace
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class RollbackResult:
24
+ """Exact paths removed or left unresolved by a create-only rollback."""
25
+
26
+ removed_files: tuple[PurePosixPath, ...]
27
+ removed_directories: tuple[PurePosixPath, ...]
28
+ unresolved: tuple[PurePosixPath, ...]
29
+ restored_files: tuple[PurePosixPath, ...] = ()
30
+
31
+ @property
32
+ def success(self) -> bool:
33
+ return not self.unresolved
34
+
35
+
36
+ def rollback_created(
37
+ workspace: Workspace,
38
+ *,
39
+ files: tuple[PurePosixPath, ...],
40
+ directories: tuple[PurePosixPath, ...],
41
+ ) -> RollbackResult:
42
+ """Remove only paths confirmed as created by the current attempt."""
43
+
44
+ policy = Policy(workspace)
45
+ removed_files: list[PurePosixPath] = []
46
+ removed_directories: list[PurePosixPath] = []
47
+ unresolved: list[PurePosixPath] = []
48
+
49
+ for path in reversed(files):
50
+ try:
51
+ target = policy.resolve(path, allow_missing=True)
52
+ if target.kind is PathKind.MISSING:
53
+ continue
54
+ if target.kind is not PathKind.FILE:
55
+ unresolved.append(path)
56
+ continue
57
+ target.absolute.unlink()
58
+ removed_files.append(path)
59
+ except (OSError, PolicyError):
60
+ unresolved.append(path)
61
+
62
+ for path in reversed(directories):
63
+ try:
64
+ target = policy.resolve(path, allow_missing=True)
65
+ if target.kind is PathKind.MISSING:
66
+ continue
67
+ if target.kind is not PathKind.DIRECTORY:
68
+ unresolved.append(path)
69
+ continue
70
+ target.absolute.rmdir()
71
+ removed_directories.append(path)
72
+ except (OSError, PolicyError):
73
+ unresolved.append(path)
74
+
75
+ return RollbackResult(
76
+ removed_files=tuple(removed_files),
77
+ removed_directories=tuple(removed_directories),
78
+ unresolved=tuple(unresolved),
79
+ )
80
+
81
+
82
+ def rollback_transaction(
83
+ workspace: Workspace,
84
+ backup: PreparedBackup | LoadedBackup,
85
+ *,
86
+ modified_files: tuple[PurePosixPath, ...],
87
+ files: tuple[PurePosixPath, ...],
88
+ directories: tuple[PurePosixPath, ...],
89
+ ) -> RollbackResult:
90
+ """Restore modified originals, then remove paths created by the attempt."""
91
+
92
+ restored_files: list[PurePosixPath] = []
93
+ unresolved: list[PurePosixPath] = []
94
+ for path in reversed(modified_files):
95
+ try:
96
+ _restore_original(workspace, backup, path)
97
+ restored_files.append(path)
98
+ except (KeyError, OSError, PolicyError):
99
+ unresolved.append(path)
100
+
101
+ created = rollback_created(
102
+ workspace,
103
+ files=files,
104
+ directories=directories,
105
+ )
106
+ return RollbackResult(
107
+ removed_files=created.removed_files,
108
+ removed_directories=created.removed_directories,
109
+ unresolved=(*unresolved, *created.unresolved),
110
+ restored_files=tuple(restored_files),
111
+ )
112
+
113
+
114
+ def rollback_completed_backup(
115
+ workspace: Workspace,
116
+ backup: LoadedBackup,
117
+ ) -> RollbackResult:
118
+ """Preflight and roll back one previously completed transaction."""
119
+
120
+ _preflight_manual_rollback(workspace, backup)
121
+ modified = tuple(
122
+ entry.path
123
+ for entry in backup.entries
124
+ if entry.kind is BackupEntryKind.FILE
125
+ and entry.original_state is OriginalState.PRESENT
126
+ )
127
+ created_files = tuple(
128
+ entry.path
129
+ for entry in backup.entries
130
+ if entry.kind is BackupEntryKind.FILE
131
+ and entry.original_state is OriginalState.ABSENT
132
+ )
133
+ created_directories = tuple(
134
+ entry.path
135
+ for entry in backup.entries
136
+ if entry.kind is BackupEntryKind.DIRECTORY
137
+ and entry.original_state is OriginalState.ABSENT
138
+ )
139
+ return rollback_transaction(
140
+ workspace,
141
+ backup,
142
+ modified_files=modified,
143
+ files=created_files,
144
+ directories=created_directories,
145
+ )
146
+
147
+
148
+ def _restore_original(
149
+ workspace: Workspace,
150
+ backup: PreparedBackup | LoadedBackup,
151
+ path: PurePosixPath,
152
+ ) -> None:
153
+ entry = backup.entry_for(path)
154
+ if (
155
+ entry.original_state is not OriginalState.PRESENT
156
+ or entry.backup_path is None
157
+ or entry.original_sha256 is None
158
+ or entry.original_size is None
159
+ or entry.original_mode is None
160
+ ):
161
+ raise OSError(f"backup entry cannot restore a modified file: {path}")
162
+
163
+ raw = _read_original(backup, path)
164
+
165
+ actions.atomic_restore_file(
166
+ workspace,
167
+ path,
168
+ raw,
169
+ mode=entry.original_mode,
170
+ )
171
+ actions.verify_restored_file(
172
+ workspace,
173
+ path,
174
+ raw,
175
+ mode=entry.original_mode,
176
+ )
177
+
178
+
179
+ def _preflight_manual_rollback(
180
+ workspace: Workspace,
181
+ backup: LoadedBackup,
182
+ ) -> None:
183
+ policy = Policy(workspace)
184
+ allowed = frozenset(entry.path for entry in backup.entries)
185
+ for entry in backup.entries:
186
+ try:
187
+ target = policy.resolve(entry.path)
188
+ metadata = target.absolute.lstat()
189
+ if stat.S_IMODE(metadata.st_mode) != entry.applied_mode:
190
+ raise OSError("applied mode changed")
191
+ if entry.kind is BackupEntryKind.FILE:
192
+ if (
193
+ target.kind is not PathKind.FILE
194
+ or entry.applied_size is None
195
+ or entry.applied_sha256 is None
196
+ ):
197
+ raise OSError("applied file state is incomplete")
198
+ before = target.absolute.lstat()
199
+ raw = target.absolute.read_bytes()
200
+ after = target.absolute.lstat()
201
+ if (
202
+ _identity(before) != _identity(after)
203
+ or len(raw) != entry.applied_size
204
+ or hashlib.sha256(raw).hexdigest() != entry.applied_sha256
205
+ ):
206
+ raise OSError("applied file changed after the job")
207
+ elif target.kind is not PathKind.DIRECTORY:
208
+ raise OSError("applied directory has the wrong type")
209
+ if entry.original_state is OriginalState.PRESENT:
210
+ _read_original(backup, entry.path)
211
+ except (KeyError, OSError, PolicyError) as exc:
212
+ raise ExecutionError(
213
+ ExecutionErrorCode.ROLLBACK_FAILED,
214
+ "manual rollback refused because the applied state changed or the backup is unsafe",
215
+ path=entry.path.as_posix(),
216
+ backup_path=backup.path,
217
+ rollback_succeeded=False,
218
+ ) from exc
219
+
220
+ for entry in backup.entries:
221
+ if entry.kind is not BackupEntryKind.DIRECTORY:
222
+ continue
223
+ target = workspace.root.joinpath(*entry.path.parts)
224
+ pending = [target]
225
+ while pending:
226
+ directory = pending.pop()
227
+ try:
228
+ children = tuple(directory.iterdir())
229
+ except OSError as exc:
230
+ raise ExecutionError(
231
+ ExecutionErrorCode.ROLLBACK_FAILED,
232
+ "manual rollback could not inspect a created directory",
233
+ path=entry.path.as_posix(),
234
+ backup_path=backup.path,
235
+ rollback_succeeded=False,
236
+ ) from exc
237
+ for child in children:
238
+ relative = PurePosixPath(child.relative_to(workspace.root).as_posix())
239
+ if relative not in allowed:
240
+ raise ExecutionError(
241
+ ExecutionErrorCode.ROLLBACK_FAILED,
242
+ "manual rollback refused to remove a directory containing undeclared entries",
243
+ path=relative.as_posix(),
244
+ backup_path=backup.path,
245
+ rollback_succeeded=False,
246
+ )
247
+ try:
248
+ mode = child.lstat().st_mode
249
+ except OSError as exc:
250
+ raise ExecutionError(
251
+ ExecutionErrorCode.ROLLBACK_FAILED,
252
+ "manual rollback could not inspect a created path",
253
+ path=relative.as_posix(),
254
+ backup_path=backup.path,
255
+ rollback_succeeded=False,
256
+ ) from exc
257
+ if stat.S_ISDIR(mode):
258
+ pending.append(child)
259
+
260
+
261
+ def _read_original(
262
+ backup: PreparedBackup | LoadedBackup,
263
+ path: PurePosixPath,
264
+ ) -> bytes:
265
+ entry = backup.entry_for(path)
266
+ if (
267
+ entry.original_state is not OriginalState.PRESENT
268
+ or entry.backup_path is None
269
+ or entry.original_sha256 is None
270
+ or entry.original_size is None
271
+ or entry.original_mode is None
272
+ ):
273
+ raise OSError(f"backup entry cannot restore a modified file: {path}")
274
+ source = backup.path.joinpath(*entry.backup_path.parts)
275
+ metadata = source.lstat()
276
+ if (
277
+ stat.S_ISLNK(metadata.st_mode)
278
+ or not stat.S_ISREG(metadata.st_mode)
279
+ or source.resolve() != source.absolute()
280
+ ):
281
+ raise OSError(f"original backup is not a regular file: {path}")
282
+ raw = source.read_bytes()
283
+ if (
284
+ len(raw) != entry.original_size
285
+ or hashlib.sha256(raw).hexdigest() != entry.original_sha256
286
+ ):
287
+ raise OSError(f"original backup failed integrity validation: {path}")
288
+ return raw
289
+
290
+
291
+ def _identity(metadata) -> tuple[int, int, int, int, int]:
292
+ return (
293
+ metadata.st_dev,
294
+ metadata.st_ino,
295
+ metadata.st_size,
296
+ metadata.st_mtime_ns,
297
+ metadata.st_mode,
298
+ )
299
+
300
+
301
+ __all__ = [
302
+ "RollbackResult",
303
+ "rollback_completed_backup",
304
+ "rollback_created",
305
+ "rollback_transaction",
306
+ ]