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/audit.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""Bounded read-only audit action execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from patchshuttle._process import ProcessCommand, ProcessStatus, run_process
|
|
17
|
+
from patchshuttle._version import __version__
|
|
18
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode, PolicyError
|
|
19
|
+
from patchshuttle.inventory import (
|
|
20
|
+
InventoryError,
|
|
21
|
+
WorkspaceComparison,
|
|
22
|
+
capture_inventory,
|
|
23
|
+
compare_inventories,
|
|
24
|
+
)
|
|
25
|
+
from patchshuttle.models import JobKind
|
|
26
|
+
from patchshuttle.planner import Plan, plan_job
|
|
27
|
+
from patchshuttle.policy import PathKind, Policy, WorkspacePath
|
|
28
|
+
|
|
29
|
+
_UTF32_LE_BOM = b"\xff\xfe\x00\x00"
|
|
30
|
+
_UTF32_BE_BOM = b"\x00\x00\xfe\xff"
|
|
31
|
+
_UTF16_LE_BOM = b"\xff\xfe"
|
|
32
|
+
_UTF16_BE_BOM = b"\xfe\xff"
|
|
33
|
+
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
34
|
+
_TRUNCATION_MARKER = "\n[TRUNCATED BY PATCHSHUTTLE]\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditStatus(str):
|
|
38
|
+
"""Stable audit action outcomes."""
|
|
39
|
+
|
|
40
|
+
COMPLETED = "COMPLETED"
|
|
41
|
+
NOT_AVAILABLE = "NOT_AVAILABLE"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class AuditActionResult:
|
|
46
|
+
"""One bounded audit action observation."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
status: str
|
|
51
|
+
scope: tuple[PurePosixPath, ...]
|
|
52
|
+
started_at: str
|
|
53
|
+
duration_ms: int
|
|
54
|
+
output: str = field(repr=False)
|
|
55
|
+
output_truncated: bool = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def success(self) -> bool:
|
|
59
|
+
return self.status in {AuditStatus.COMPLETED, AuditStatus.NOT_AVAILABLE}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class AuditRunResult:
|
|
64
|
+
"""Ordered results from a read-only audit plan."""
|
|
65
|
+
|
|
66
|
+
plan: Plan = field(repr=False)
|
|
67
|
+
results: tuple[AuditActionResult, ...]
|
|
68
|
+
workspace_comparison: WorkspaceComparison
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class _WalkEntry:
|
|
73
|
+
path: PurePosixPath
|
|
74
|
+
absolute: Path
|
|
75
|
+
mode: int
|
|
76
|
+
depth: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def execute_audit_locked(plan: Plan) -> AuditRunResult:
|
|
80
|
+
"""Execute one audit while the caller holds the workspace run lock."""
|
|
81
|
+
|
|
82
|
+
if plan.job.kind is not JobKind.AUDIT:
|
|
83
|
+
raise ExecutionError(
|
|
84
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
85
|
+
"the audit runner accepts only audit jobs",
|
|
86
|
+
)
|
|
87
|
+
_revalidate_plan(plan)
|
|
88
|
+
baseline = _capture_inventory(plan)
|
|
89
|
+
results: list[AuditActionResult] = []
|
|
90
|
+
for action, planned in zip(plan.job.actions, plan.actions):
|
|
91
|
+
started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
92
|
+
started = time.monotonic_ns()
|
|
93
|
+
try:
|
|
94
|
+
status, output = _execute_action(plan, action.name, action.parameters)
|
|
95
|
+
output, truncated = _bounded_output(
|
|
96
|
+
output,
|
|
97
|
+
plan.workspace.config.execution.max_command_output_bytes,
|
|
98
|
+
)
|
|
99
|
+
truncated = truncated or _TRUNCATION_MARKER.strip() in output
|
|
100
|
+
except ExecutionError as error:
|
|
101
|
+
error.item_id = error.item_id or planned.id
|
|
102
|
+
error.path = error.path or (
|
|
103
|
+
planned.paths[0].as_posix() if planned.paths else None
|
|
104
|
+
)
|
|
105
|
+
error.audit_results = tuple(results)
|
|
106
|
+
raise
|
|
107
|
+
except (OSError, PolicyError, UnicodeError, ValueError) as exc:
|
|
108
|
+
raise ExecutionError(
|
|
109
|
+
ExecutionErrorCode.ACTION_FAILED,
|
|
110
|
+
"a read-only audit action failed",
|
|
111
|
+
item_id=planned.id,
|
|
112
|
+
path=(planned.paths[0].as_posix() if planned.paths else None),
|
|
113
|
+
audit_results=tuple(results),
|
|
114
|
+
) from exc
|
|
115
|
+
results.append(
|
|
116
|
+
AuditActionResult(
|
|
117
|
+
id=planned.id,
|
|
118
|
+
name=planned.name,
|
|
119
|
+
status=status,
|
|
120
|
+
scope=planned.paths,
|
|
121
|
+
started_at=started_at,
|
|
122
|
+
duration_ms=(time.monotonic_ns() - started) // 1_000_000,
|
|
123
|
+
output=output,
|
|
124
|
+
output_truncated=truncated,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
current = _capture_inventory(plan)
|
|
128
|
+
comparison = compare_inventories(baseline, current)
|
|
129
|
+
if comparison.unexpected_changes:
|
|
130
|
+
first = comparison.unexpected_changes[0]
|
|
131
|
+
raise ExecutionError(
|
|
132
|
+
ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
|
|
133
|
+
"an audit action changed the workspace",
|
|
134
|
+
path=first.path.as_posix(),
|
|
135
|
+
audit_results=tuple(results),
|
|
136
|
+
workspace_comparison=comparison,
|
|
137
|
+
)
|
|
138
|
+
return AuditRunResult(
|
|
139
|
+
plan=plan,
|
|
140
|
+
results=tuple(results),
|
|
141
|
+
workspace_comparison=comparison,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _execute_action(plan: Plan, name: str, parameters) -> tuple[str, str]:
|
|
146
|
+
if name == "tree":
|
|
147
|
+
return AuditStatus.COMPLETED, _tree(plan, parameters)
|
|
148
|
+
if name == "read":
|
|
149
|
+
return AuditStatus.COMPLETED, _read(plan, parameters)
|
|
150
|
+
if name == "search":
|
|
151
|
+
return AuditStatus.COMPLETED, _search(plan, parameters)
|
|
152
|
+
if name == "find_files":
|
|
153
|
+
return AuditStatus.COMPLETED, _find_files(plan, parameters)
|
|
154
|
+
if name == "file_info":
|
|
155
|
+
return AuditStatus.COMPLETED, _file_info(plan, parameters)
|
|
156
|
+
if name == "hash":
|
|
157
|
+
return AuditStatus.COMPLETED, _hash(plan, parameters)
|
|
158
|
+
if name == "git_status":
|
|
159
|
+
return _git_status(plan)
|
|
160
|
+
if name == "environment":
|
|
161
|
+
return AuditStatus.COMPLETED, _environment(plan)
|
|
162
|
+
raise ExecutionError(
|
|
163
|
+
ExecutionErrorCode.ACTION_UNSUPPORTED,
|
|
164
|
+
"the audit runner does not support this action",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _tree(plan: Plan, parameters) -> str:
|
|
169
|
+
policy = Policy(plan.workspace)
|
|
170
|
+
root = policy.resolve(parameters.path, allow_root=True)
|
|
171
|
+
lines = [f"{_display(root.relative)}/ [directory]"]
|
|
172
|
+
entries = _walk(
|
|
173
|
+
plan,
|
|
174
|
+
root,
|
|
175
|
+
maximum_depth=parameters.depth,
|
|
176
|
+
include_hidden=parameters.include_hidden,
|
|
177
|
+
)
|
|
178
|
+
limited = False
|
|
179
|
+
for entry in entries:
|
|
180
|
+
if len(lines) - 1 >= parameters.max_entries:
|
|
181
|
+
limited = True
|
|
182
|
+
break
|
|
183
|
+
kind = _mode_name(entry.mode)
|
|
184
|
+
suffix = "/" if stat.S_ISDIR(entry.mode) else ""
|
|
185
|
+
lines.append(f"{entry.path.as_posix()}{suffix} [{kind}]")
|
|
186
|
+
if limited:
|
|
187
|
+
lines.append("[ENTRY LIMIT REACHED]")
|
|
188
|
+
return "\n".join(lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read(plan: Plan, parameters) -> str:
|
|
192
|
+
policy = Policy(plan.workspace)
|
|
193
|
+
target = policy.resolve(parameters.path)
|
|
194
|
+
raw = _read_regular_file(plan, target)
|
|
195
|
+
encoding, text = _decode_text(raw)
|
|
196
|
+
lines = text.splitlines()
|
|
197
|
+
start = parameters.start_line
|
|
198
|
+
end = parameters.end_line or len(lines)
|
|
199
|
+
selected = [
|
|
200
|
+
f"{number:>6}: {lines[number - 1]}"
|
|
201
|
+
for number in range(start, min(end, len(lines)) + 1)
|
|
202
|
+
]
|
|
203
|
+
header = f"path: {target.relative.as_posix()}\nencoding: {encoding}"
|
|
204
|
+
output = header + ("\n" + "\n".join(selected) if selected else "\n[NO LINES]")
|
|
205
|
+
limit = (
|
|
206
|
+
parameters.max_bytes or plan.workspace.config.execution.max_single_file_bytes
|
|
207
|
+
)
|
|
208
|
+
bounded, _ = _bounded_output(output, limit)
|
|
209
|
+
return bounded
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _search(plan: Plan, parameters) -> str:
|
|
213
|
+
policy = Policy(plan.workspace)
|
|
214
|
+
root = policy.resolve(parameters.path, allow_root=True)
|
|
215
|
+
files = _audit_files(plan, root, glob=parameters.glob)
|
|
216
|
+
needle = (
|
|
217
|
+
parameters.text if parameters.case_sensitive else parameters.text.casefold()
|
|
218
|
+
)
|
|
219
|
+
results: list[str] = []
|
|
220
|
+
skipped_binary = 0
|
|
221
|
+
for path, target in files:
|
|
222
|
+
try:
|
|
223
|
+
_, text = _decode_text(_read_regular_file(plan, target))
|
|
224
|
+
except (UnicodeError, ValueError):
|
|
225
|
+
skipped_binary += 1
|
|
226
|
+
continue
|
|
227
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
228
|
+
compared = line if parameters.case_sensitive else line.casefold()
|
|
229
|
+
if needle in compared:
|
|
230
|
+
results.append(f"{path.as_posix()}:{number}:{line}")
|
|
231
|
+
if len(results) >= parameters.max_results:
|
|
232
|
+
break
|
|
233
|
+
if len(results) >= parameters.max_results:
|
|
234
|
+
break
|
|
235
|
+
header = [
|
|
236
|
+
f"literal: {parameters.text}",
|
|
237
|
+
f"case_sensitive: {str(parameters.case_sensitive).lower()}",
|
|
238
|
+
f"matches: {len(results)}",
|
|
239
|
+
f"binary_files_skipped: {skipped_binary}",
|
|
240
|
+
]
|
|
241
|
+
if len(results) >= parameters.max_results:
|
|
242
|
+
header.append("result_limit_reached: true")
|
|
243
|
+
return "\n".join((*header, *results))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _find_files(plan: Plan, parameters) -> str:
|
|
247
|
+
policy = Policy(plan.workspace)
|
|
248
|
+
root = policy.resolve(parameters.path, allow_root=True)
|
|
249
|
+
found: list[str] = []
|
|
250
|
+
for path, _ in _audit_files(plan, root, glob=parameters.glob):
|
|
251
|
+
found.append(path.as_posix())
|
|
252
|
+
if len(found) >= parameters.max_results:
|
|
253
|
+
break
|
|
254
|
+
lines = [f"glob: {parameters.glob}", f"matches: {len(found)}", *found]
|
|
255
|
+
if len(found) >= parameters.max_results:
|
|
256
|
+
lines.insert(2, "result_limit_reached: true")
|
|
257
|
+
return "\n".join(lines)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _file_info(plan: Plan, parameters) -> str:
|
|
261
|
+
policy = Policy(plan.workspace)
|
|
262
|
+
target = policy.resolve(parameters.path)
|
|
263
|
+
metadata_value = target.absolute.lstat()
|
|
264
|
+
values = [
|
|
265
|
+
f"path: {target.relative.as_posix()}",
|
|
266
|
+
f"type: {target.kind.value}",
|
|
267
|
+
f"size_bytes: {metadata_value.st_size if target.kind is PathKind.FILE else 0}",
|
|
268
|
+
f"executable: {str(bool(metadata_value.st_mode & 0o111)).lower()}",
|
|
269
|
+
"modified_at: "
|
|
270
|
+
+ datetime.fromtimestamp(
|
|
271
|
+
metadata_value.st_mtime,
|
|
272
|
+
tz=timezone.utc,
|
|
273
|
+
).isoformat(timespec="seconds"),
|
|
274
|
+
]
|
|
275
|
+
if target.kind is PathKind.FILE:
|
|
276
|
+
raw = _read_regular_file(plan, target)
|
|
277
|
+
try:
|
|
278
|
+
encoding, text = _decode_text(raw)
|
|
279
|
+
newline = _newline_style(text)
|
|
280
|
+
except (UnicodeError, ValueError):
|
|
281
|
+
encoding, newline = "binary_or_unsupported", "not_applicable"
|
|
282
|
+
values.extend((f"encoding: {encoding}", f"newline: {newline}"))
|
|
283
|
+
return "\n".join(values)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _hash(plan: Plan, parameters) -> str:
|
|
287
|
+
target = Policy(plan.workspace).resolve(parameters.path)
|
|
288
|
+
raw = _read_regular_file(plan, target)
|
|
289
|
+
return "\n".join(
|
|
290
|
+
(
|
|
291
|
+
f"path: {target.relative.as_posix()}",
|
|
292
|
+
"algorithm: sha256",
|
|
293
|
+
f"sha256: {hashlib.sha256(raw).hexdigest()}",
|
|
294
|
+
f"size_bytes: {len(raw)}",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _git_status(plan: Plan) -> tuple[str, str]:
|
|
300
|
+
git = shutil.which("git")
|
|
301
|
+
marker = plan.workspace.root / ".git"
|
|
302
|
+
if git is None or not marker.exists() or marker.is_symlink():
|
|
303
|
+
return AuditStatus.NOT_AVAILABLE, "Git repository or executable not available"
|
|
304
|
+
process = run_process(
|
|
305
|
+
ProcessCommand(
|
|
306
|
+
argv=(
|
|
307
|
+
git,
|
|
308
|
+
"-c",
|
|
309
|
+
"color.ui=false",
|
|
310
|
+
"status",
|
|
311
|
+
"--short",
|
|
312
|
+
"--branch",
|
|
313
|
+
"--untracked-files=normal",
|
|
314
|
+
),
|
|
315
|
+
working_directory=plan.workspace.root,
|
|
316
|
+
timeout_seconds=plan.workspace.config.execution.default_timeout_seconds,
|
|
317
|
+
),
|
|
318
|
+
maximum_output_bytes=plan.workspace.config.execution.max_command_output_bytes,
|
|
319
|
+
)
|
|
320
|
+
if process.status is not ProcessStatus.PASSED:
|
|
321
|
+
raise OSError(process.stderr or "git status failed")
|
|
322
|
+
output = process.stdout or "[CLEAN WORKTREE]"
|
|
323
|
+
if process.stdout_truncated:
|
|
324
|
+
output += _TRUNCATION_MARKER
|
|
325
|
+
return AuditStatus.COMPLETED, output.rstrip("\n")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _environment(plan: Plan) -> str:
|
|
329
|
+
values = {
|
|
330
|
+
"operating_system": platform.platform(),
|
|
331
|
+
"python_implementation": platform.python_implementation(),
|
|
332
|
+
"python_version": platform.python_version(),
|
|
333
|
+
"patchshuttle_version": __version__,
|
|
334
|
+
"project_id": plan.workspace.project_id,
|
|
335
|
+
"working_directory": _redacted_cwd(plan.workspace.root),
|
|
336
|
+
"git": _tool_version("git"),
|
|
337
|
+
"pytest": _package_version("pytest"),
|
|
338
|
+
"isort": _package_version("isort"),
|
|
339
|
+
"black": _package_version("black"),
|
|
340
|
+
}
|
|
341
|
+
return "\n".join(f"{key}: {value}" for key, value in values.items())
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _walk(
|
|
345
|
+
plan: Plan,
|
|
346
|
+
root: WorkspacePath,
|
|
347
|
+
*,
|
|
348
|
+
maximum_depth: int,
|
|
349
|
+
include_hidden: bool,
|
|
350
|
+
) -> tuple[_WalkEntry, ...]:
|
|
351
|
+
policy = Policy(plan.workspace)
|
|
352
|
+
pending = [(root.absolute, root.relative, 0)]
|
|
353
|
+
result: list[_WalkEntry] = []
|
|
354
|
+
inspected = 0
|
|
355
|
+
maximum = plan.workspace.config.execution.max_inventory_entries
|
|
356
|
+
while pending:
|
|
357
|
+
directory, parent, depth = pending.pop()
|
|
358
|
+
try:
|
|
359
|
+
with os.scandir(directory) as iterator:
|
|
360
|
+
children = sorted(iterator, key=lambda item: item.name)
|
|
361
|
+
except OSError as exc:
|
|
362
|
+
raise OSError(f"could not inspect {parent.as_posix()}") from exc
|
|
363
|
+
directories: list[tuple[Path, PurePosixPath, int]] = []
|
|
364
|
+
for child in children:
|
|
365
|
+
relative = parent / child.name
|
|
366
|
+
if (not include_hidden and child.name.startswith(".")) or _skip(
|
|
367
|
+
policy,
|
|
368
|
+
relative,
|
|
369
|
+
):
|
|
370
|
+
continue
|
|
371
|
+
inspected += 1
|
|
372
|
+
if inspected > maximum:
|
|
373
|
+
raise OSError("audit traversal exceeded the configured entry limit")
|
|
374
|
+
try:
|
|
375
|
+
metadata_value = child.stat(follow_symlinks=False)
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
raise OSError(f"could not inspect {relative.as_posix()}") from exc
|
|
378
|
+
result.append(
|
|
379
|
+
_WalkEntry(
|
|
380
|
+
path=relative,
|
|
381
|
+
absolute=Path(child.path),
|
|
382
|
+
mode=metadata_value.st_mode,
|
|
383
|
+
depth=depth + 1,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
if stat.S_ISDIR(metadata_value.st_mode) and depth + 1 < maximum_depth:
|
|
387
|
+
directories.append((Path(child.path), relative, depth + 1))
|
|
388
|
+
pending.extend(reversed(directories))
|
|
389
|
+
return tuple(result)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _audit_files(
|
|
393
|
+
plan: Plan,
|
|
394
|
+
root: WorkspacePath,
|
|
395
|
+
*,
|
|
396
|
+
glob: str | None,
|
|
397
|
+
) -> tuple[tuple[PurePosixPath, WorkspacePath], ...]:
|
|
398
|
+
policy = Policy(plan.workspace)
|
|
399
|
+
if root.kind is PathKind.FILE:
|
|
400
|
+
candidates = (root.relative,)
|
|
401
|
+
base = root.relative.parent
|
|
402
|
+
else:
|
|
403
|
+
entries = _walk(
|
|
404
|
+
plan,
|
|
405
|
+
root,
|
|
406
|
+
maximum_depth=10_000,
|
|
407
|
+
include_hidden=True,
|
|
408
|
+
)
|
|
409
|
+
candidates = tuple(entry.path for entry in entries if stat.S_ISREG(entry.mode))
|
|
410
|
+
base = root.relative
|
|
411
|
+
files: list[tuple[PurePosixPath, WorkspacePath]] = []
|
|
412
|
+
for path in candidates:
|
|
413
|
+
compared = path.relative_to(base) if base.parts else path
|
|
414
|
+
if glob is not None and not (
|
|
415
|
+
compared.match(glob) or PurePosixPath(path.name).match(glob)
|
|
416
|
+
):
|
|
417
|
+
continue
|
|
418
|
+
target = policy.resolve(path)
|
|
419
|
+
if target.kind is PathKind.FILE:
|
|
420
|
+
files.append((path, target))
|
|
421
|
+
return tuple(files)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _read_regular_file(plan: Plan, target: WorkspacePath) -> bytes:
|
|
425
|
+
if target.kind is not PathKind.FILE:
|
|
426
|
+
raise OSError("audit target is not a regular file")
|
|
427
|
+
before = target.absolute.lstat()
|
|
428
|
+
maximum = plan.workspace.config.execution.max_single_file_bytes
|
|
429
|
+
if before.st_size > maximum:
|
|
430
|
+
raise OSError("audit file exceeds the configured size limit")
|
|
431
|
+
raw = target.absolute.read_bytes()
|
|
432
|
+
after = target.absolute.lstat()
|
|
433
|
+
current = Policy(plan.workspace).resolve(target.relative)
|
|
434
|
+
identity = lambda item: (
|
|
435
|
+
item.st_dev,
|
|
436
|
+
item.st_ino,
|
|
437
|
+
item.st_size,
|
|
438
|
+
item.st_mtime_ns,
|
|
439
|
+
item.st_mode,
|
|
440
|
+
)
|
|
441
|
+
if (
|
|
442
|
+
len(raw) > maximum
|
|
443
|
+
or identity(before) != identity(after)
|
|
444
|
+
or current.kind is not PathKind.FILE
|
|
445
|
+
or current.absolute != target.absolute
|
|
446
|
+
):
|
|
447
|
+
raise OSError("audit file changed while it was read")
|
|
448
|
+
return raw
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _decode_text(raw: bytes) -> tuple[str, str]:
|
|
452
|
+
bom = b""
|
|
453
|
+
if raw.startswith(_UTF32_LE_BOM):
|
|
454
|
+
bom, codec, encoding = _UTF32_LE_BOM, "utf-32-le", "utf-32-le"
|
|
455
|
+
elif raw.startswith(_UTF32_BE_BOM):
|
|
456
|
+
bom, codec, encoding = _UTF32_BE_BOM, "utf-32-be", "utf-32-be"
|
|
457
|
+
elif raw.startswith(_UTF8_BOM):
|
|
458
|
+
bom, codec, encoding = _UTF8_BOM, "utf-8", "utf-8-sig"
|
|
459
|
+
elif raw.startswith(_UTF16_LE_BOM):
|
|
460
|
+
bom, codec, encoding = _UTF16_LE_BOM, "utf-16-le", "utf-16-le"
|
|
461
|
+
elif raw.startswith(_UTF16_BE_BOM):
|
|
462
|
+
bom, codec, encoding = _UTF16_BE_BOM, "utf-16-be", "utf-16-be"
|
|
463
|
+
else:
|
|
464
|
+
codec = encoding = "utf-8"
|
|
465
|
+
if b"\0" in raw:
|
|
466
|
+
raise ValueError("binary file")
|
|
467
|
+
text = raw[len(bom) :].decode(codec)
|
|
468
|
+
if any(_is_binary_control(character) for character in text):
|
|
469
|
+
raise ValueError("binary file")
|
|
470
|
+
return encoding, text
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _newline_style(text: str) -> str:
|
|
474
|
+
without_crlf = text.replace("\r\n", "")
|
|
475
|
+
has_crlf = "\r\n" in text
|
|
476
|
+
has_lf = "\n" in without_crlf
|
|
477
|
+
if "\r" in without_crlf or (has_crlf and has_lf):
|
|
478
|
+
return "mixed_or_cr"
|
|
479
|
+
if has_crlf:
|
|
480
|
+
return "crlf"
|
|
481
|
+
if has_lf:
|
|
482
|
+
return "lf"
|
|
483
|
+
return "none"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _bounded_output(value: str, maximum: int) -> tuple[str, bool]:
|
|
487
|
+
raw = value.encode("utf-8")
|
|
488
|
+
if len(raw) <= maximum:
|
|
489
|
+
return value, False
|
|
490
|
+
marker = _TRUNCATION_MARKER.encode("utf-8")
|
|
491
|
+
if maximum <= len(marker):
|
|
492
|
+
return marker[:maximum].decode("utf-8"), True
|
|
493
|
+
retained = raw[: max(0, maximum - len(marker))]
|
|
494
|
+
return retained.decode("utf-8", errors="ignore") + _TRUNCATION_MARKER, True
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _skip(policy: Policy, path: PurePosixPath) -> bool:
|
|
498
|
+
return policy.is_ignored(path) or policy.is_protected(path)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _mode_name(mode: int) -> str:
|
|
502
|
+
if stat.S_ISREG(mode):
|
|
503
|
+
return "file"
|
|
504
|
+
if stat.S_ISDIR(mode):
|
|
505
|
+
return "directory"
|
|
506
|
+
if stat.S_ISLNK(mode):
|
|
507
|
+
return "symlink"
|
|
508
|
+
return "other"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _display(path: PurePosixPath) -> str:
|
|
512
|
+
return path.as_posix() if path.parts else "."
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _redacted_cwd(path: Path) -> str:
|
|
516
|
+
try:
|
|
517
|
+
home = Path.home().resolve()
|
|
518
|
+
resolved = path.resolve()
|
|
519
|
+
if resolved == home:
|
|
520
|
+
return "~"
|
|
521
|
+
return "~/" + resolved.relative_to(home).as_posix()
|
|
522
|
+
except (OSError, ValueError):
|
|
523
|
+
return path.as_posix()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _package_version(name: str) -> str:
|
|
527
|
+
try:
|
|
528
|
+
return metadata.version(name)
|
|
529
|
+
except metadata.PackageNotFoundError:
|
|
530
|
+
return "NOT_AVAILABLE"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _tool_version(name: str) -> str:
|
|
534
|
+
executable = shutil.which(name)
|
|
535
|
+
if executable is None:
|
|
536
|
+
return "NOT_AVAILABLE"
|
|
537
|
+
process = run_process(
|
|
538
|
+
ProcessCommand(
|
|
539
|
+
argv=(executable, "--version"),
|
|
540
|
+
working_directory=Path.cwd(),
|
|
541
|
+
timeout_seconds=10,
|
|
542
|
+
),
|
|
543
|
+
maximum_output_bytes=4096,
|
|
544
|
+
)
|
|
545
|
+
if process.status is not ProcessStatus.PASSED:
|
|
546
|
+
return "NOT_AVAILABLE"
|
|
547
|
+
return (process.stdout or process.stderr).strip() or "AVAILABLE"
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _is_binary_control(character: str) -> bool:
|
|
551
|
+
value = ord(character)
|
|
552
|
+
return (value < 32 and character not in "\t\n\r") or value == 127
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _revalidate_plan(plan: Plan) -> None:
|
|
556
|
+
try:
|
|
557
|
+
current = plan_job(plan.job, plan.workspace)
|
|
558
|
+
except (OSError, PolicyError, ValueError) as exc:
|
|
559
|
+
raise ExecutionError(
|
|
560
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
561
|
+
"the workspace no longer matches the approved audit plan",
|
|
562
|
+
item_id=getattr(exc, "item_id", None),
|
|
563
|
+
path=getattr(exc, "path", None),
|
|
564
|
+
) from exc
|
|
565
|
+
if current != plan:
|
|
566
|
+
raise ExecutionError(
|
|
567
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
568
|
+
"the workspace no longer matches the approved audit plan",
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _capture_inventory(plan: Plan):
|
|
573
|
+
try:
|
|
574
|
+
return capture_inventory(plan.workspace)
|
|
575
|
+
except InventoryError as exc:
|
|
576
|
+
raise ExecutionError(
|
|
577
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
578
|
+
"audit workspace inventory could not be captured",
|
|
579
|
+
path=exc.path.as_posix() if exc.path is not None else None,
|
|
580
|
+
) from exc
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
__all__ = [
|
|
584
|
+
"AuditActionResult",
|
|
585
|
+
"AuditRunResult",
|
|
586
|
+
"AuditStatus",
|
|
587
|
+
"execute_audit_locked",
|
|
588
|
+
]
|