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
|
@@ -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,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."""
|
patchshuttle/rollback.py
ADDED
|
@@ -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
|
+
]
|