@softspark/ai-toolkit 4.14.1 → 4.15.0
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.
- package/CHANGELOG.md +28 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Pinned-directory filesystem mutations for trusted configuration roots.
|
|
2
|
+
|
|
3
|
+
All mutating syscalls operate relative to directory descriptors opened with
|
|
4
|
+
``O_NOFOLLOW``. Parent descriptors stay open for the whole transaction, so an
|
|
5
|
+
ancestor rename or symlink swap cannot redirect writes or rollback outside the
|
|
6
|
+
declared trust boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, TypeVar
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SECURE_DIR_FD = (
|
|
21
|
+
hasattr(os, "O_DIRECTORY")
|
|
22
|
+
and hasattr(os, "O_NOFOLLOW")
|
|
23
|
+
and all(
|
|
24
|
+
function in os.supports_dir_fd
|
|
25
|
+
for function in (
|
|
26
|
+
os.open,
|
|
27
|
+
os.unlink,
|
|
28
|
+
os.rmdir,
|
|
29
|
+
os.mkdir,
|
|
30
|
+
os.rename,
|
|
31
|
+
os.stat,
|
|
32
|
+
os.readlink,
|
|
33
|
+
os.symlink,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_DIRECTORY_FLAGS = (
|
|
39
|
+
os.O_RDONLY
|
|
40
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
41
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
42
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
43
|
+
)
|
|
44
|
+
_READ_FLAGS = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
45
|
+
_WRITE_FLAGS = (
|
|
46
|
+
os.O_WRONLY
|
|
47
|
+
| os.O_CREAT
|
|
48
|
+
| os.O_EXCL
|
|
49
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
50
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SecureDestination:
|
|
56
|
+
path: Path
|
|
57
|
+
trusted_root: Path
|
|
58
|
+
label: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _Snapshot:
|
|
63
|
+
existed: bool
|
|
64
|
+
content: bytes | None
|
|
65
|
+
mode: int
|
|
66
|
+
device: int | None
|
|
67
|
+
inode: int | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class _PinnedDestination:
|
|
72
|
+
destination: SecureDestination
|
|
73
|
+
parent_fd: int
|
|
74
|
+
target_name: str
|
|
75
|
+
missing_parent_parts: tuple[str, ...]
|
|
76
|
+
snapshot: _Snapshot
|
|
77
|
+
materialized: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def lexical_absolute(path: str | os.PathLike[str]) -> Path:
|
|
81
|
+
"""Return an absolute normalized path without resolving symlinks."""
|
|
82
|
+
return Path(os.path.abspath(os.path.expanduser(os.fspath(path))))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def nearest_existing_root(path: Path) -> Path:
|
|
86
|
+
"""Return the closest existing real directory at or above ``path``."""
|
|
87
|
+
candidate = lexical_absolute(path)
|
|
88
|
+
while not candidate.exists() and not candidate.is_symlink():
|
|
89
|
+
parent = candidate.parent
|
|
90
|
+
if parent == candidate:
|
|
91
|
+
break
|
|
92
|
+
candidate = parent
|
|
93
|
+
if candidate.is_symlink():
|
|
94
|
+
raise RuntimeError(f"Refusing symlinked destination ancestor: {candidate}")
|
|
95
|
+
if not candidate.is_dir():
|
|
96
|
+
raise RuntimeError(f"Destination ancestor is not a directory: {candidate}")
|
|
97
|
+
return candidate
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _open_absolute_directory(path: Path) -> int:
|
|
101
|
+
"""Resolve and open every absolute-path component from a stable root fd."""
|
|
102
|
+
absolute = lexical_absolute(path)
|
|
103
|
+
pending = deque(absolute.parts[1:])
|
|
104
|
+
descriptors = [os.open(os.sep, _DIRECTORY_FLAGS)]
|
|
105
|
+
symlink_expansions = 0
|
|
106
|
+
try:
|
|
107
|
+
while pending:
|
|
108
|
+
part = pending.popleft()
|
|
109
|
+
if part in ("", "."):
|
|
110
|
+
continue
|
|
111
|
+
if part == "..":
|
|
112
|
+
if len(descriptors) == 1:
|
|
113
|
+
raise RuntimeError(f"Trusted root escapes filesystem root: {path}")
|
|
114
|
+
os.close(descriptors.pop())
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=descriptors[-1])
|
|
118
|
+
except OSError as open_error:
|
|
119
|
+
if not pending:
|
|
120
|
+
raise open_error
|
|
121
|
+
try:
|
|
122
|
+
link_target = os.readlink(part, dir_fd=descriptors[-1])
|
|
123
|
+
except OSError:
|
|
124
|
+
raise open_error
|
|
125
|
+
symlink_expansions += 1
|
|
126
|
+
if symlink_expansions > 40:
|
|
127
|
+
raise RuntimeError(f"Too many symlinks in trusted root: {path}")
|
|
128
|
+
target_parts = list(Path(link_target).parts)
|
|
129
|
+
if os.path.isabs(link_target):
|
|
130
|
+
while len(descriptors) > 1:
|
|
131
|
+
os.close(descriptors.pop())
|
|
132
|
+
target_parts = target_parts[1:]
|
|
133
|
+
pending.extendleft(reversed(target_parts))
|
|
134
|
+
continue
|
|
135
|
+
descriptors.append(next_fd)
|
|
136
|
+
except BaseException:
|
|
137
|
+
for descriptor in reversed(descriptors):
|
|
138
|
+
try:
|
|
139
|
+
os.close(descriptor)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
raise
|
|
143
|
+
directory_fd = descriptors.pop()
|
|
144
|
+
for descriptor in reversed(descriptors):
|
|
145
|
+
try:
|
|
146
|
+
os.close(descriptor)
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
return directory_fd
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parts(destination: SecureDestination) -> tuple[Path, Path, tuple[str, ...]]:
|
|
153
|
+
path = lexical_absolute(destination.path)
|
|
154
|
+
root = lexical_absolute(destination.trusted_root)
|
|
155
|
+
try:
|
|
156
|
+
relative = path.relative_to(root)
|
|
157
|
+
except ValueError as error:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"{destination.label} escapes trusted root {root}: {path}"
|
|
160
|
+
) from error
|
|
161
|
+
if relative == Path(".") or not relative.parts or ".." in relative.parts:
|
|
162
|
+
raise RuntimeError(f"Refusing mutation of trusted root itself: {path}")
|
|
163
|
+
return path, root, relative.parts
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read_regular_file(parent_fd: int, name: str, label: str) -> _Snapshot:
|
|
167
|
+
try:
|
|
168
|
+
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return _Snapshot(False, None, 0o600, None, None)
|
|
171
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
172
|
+
raise RuntimeError(f"Refusing non-regular {label} destination: {name}")
|
|
173
|
+
|
|
174
|
+
file_fd = os.open(name, _READ_FLAGS, dir_fd=parent_fd)
|
|
175
|
+
primary_error: BaseException | None = None
|
|
176
|
+
try:
|
|
177
|
+
opened = os.fstat(file_fd)
|
|
178
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
179
|
+
raise RuntimeError(f"Refusing non-regular {label} destination: {name}")
|
|
180
|
+
with os.fdopen(file_fd, "rb") as handle:
|
|
181
|
+
file_fd = -1
|
|
182
|
+
content = handle.read()
|
|
183
|
+
except BaseException as error:
|
|
184
|
+
primary_error = error
|
|
185
|
+
raise
|
|
186
|
+
finally:
|
|
187
|
+
if file_fd >= 0:
|
|
188
|
+
try:
|
|
189
|
+
os.close(file_fd)
|
|
190
|
+
except OSError:
|
|
191
|
+
if primary_error is None:
|
|
192
|
+
raise
|
|
193
|
+
return _Snapshot(
|
|
194
|
+
True,
|
|
195
|
+
content,
|
|
196
|
+
stat.S_IMODE(opened.st_mode),
|
|
197
|
+
opened.st_dev,
|
|
198
|
+
opened.st_ino,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SecureTransaction:
|
|
203
|
+
"""Hold pinned parent descriptors for apply and byte-exact rollback."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, destinations: list[SecureDestination]) -> None:
|
|
206
|
+
if not SECURE_DIR_FD:
|
|
207
|
+
raise RuntimeError("Secure mutations require POSIX dir_fd and O_NOFOLLOW")
|
|
208
|
+
self._pinned: dict[Path, _PinnedDestination] = {}
|
|
209
|
+
self._root_fds: dict[Path, int] = {}
|
|
210
|
+
self._created_directories: list[tuple[int, str]] = []
|
|
211
|
+
self._touched: set[Path] = set()
|
|
212
|
+
try:
|
|
213
|
+
for destination in destinations:
|
|
214
|
+
path = lexical_absolute(destination.path)
|
|
215
|
+
if path in self._pinned:
|
|
216
|
+
existing_root = self._pinned[path].destination.trusted_root
|
|
217
|
+
if lexical_absolute(destination.trusted_root) != existing_root:
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
f"Conflicting trusted roots for destination: {path}"
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
self._pinned[path] = self._prepare(destination)
|
|
223
|
+
except BaseException:
|
|
224
|
+
self.close()
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
def _prepare(self, destination: SecureDestination) -> _PinnedDestination:
|
|
228
|
+
path, root, parts = _parts(destination)
|
|
229
|
+
root_fd = self._root_fds.get(root)
|
|
230
|
+
if root_fd is None:
|
|
231
|
+
try:
|
|
232
|
+
root_fd = _open_absolute_directory(root)
|
|
233
|
+
except OSError as error:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"Refusing unsafe trusted root for {destination.label}: "
|
|
236
|
+
f"{root}: {error}"
|
|
237
|
+
) from error
|
|
238
|
+
self._root_fds[root] = root_fd
|
|
239
|
+
directory_fd = os.dup(root_fd)
|
|
240
|
+
missing: list[str] = []
|
|
241
|
+
try:
|
|
242
|
+
for part in parts[:-1]:
|
|
243
|
+
if missing:
|
|
244
|
+
missing.append(part)
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
|
248
|
+
except FileNotFoundError:
|
|
249
|
+
missing.append(part)
|
|
250
|
+
continue
|
|
251
|
+
os.close(directory_fd)
|
|
252
|
+
directory_fd = next_fd
|
|
253
|
+
snapshot = (
|
|
254
|
+
_Snapshot(False, None, 0o600, None, None)
|
|
255
|
+
if missing
|
|
256
|
+
else _read_regular_file(directory_fd, parts[-1], destination.label)
|
|
257
|
+
)
|
|
258
|
+
except BaseException as error:
|
|
259
|
+
try:
|
|
260
|
+
os.close(directory_fd)
|
|
261
|
+
except OSError:
|
|
262
|
+
pass
|
|
263
|
+
if not isinstance(error, OSError):
|
|
264
|
+
raise
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
f"Refusing unsafe ancestor for {destination.label}: {path.parent}: {error}"
|
|
267
|
+
) from error
|
|
268
|
+
return _PinnedDestination(
|
|
269
|
+
SecureDestination(path, root, destination.label),
|
|
270
|
+
directory_fd,
|
|
271
|
+
parts[-1],
|
|
272
|
+
tuple(missing),
|
|
273
|
+
snapshot,
|
|
274
|
+
not missing,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def materialize_parents(self) -> None:
|
|
278
|
+
"""Create missing ancestors only after every destination is preflighted."""
|
|
279
|
+
for pinned in self._pinned.values():
|
|
280
|
+
if not pinned.missing_parent_parts:
|
|
281
|
+
continue
|
|
282
|
+
directory_fd = pinned.parent_fd
|
|
283
|
+
for part in pinned.missing_parent_parts:
|
|
284
|
+
created = False
|
|
285
|
+
try:
|
|
286
|
+
os.mkdir(part, mode=0o755, dir_fd=directory_fd)
|
|
287
|
+
created = True
|
|
288
|
+
except FileExistsError:
|
|
289
|
+
pass
|
|
290
|
+
if created:
|
|
291
|
+
self._created_directories.append((os.dup(directory_fd), part))
|
|
292
|
+
os.fsync(directory_fd)
|
|
293
|
+
try:
|
|
294
|
+
next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
|
295
|
+
except OSError as error:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"Refusing unsafe created ancestor for "
|
|
298
|
+
f"{pinned.destination.label}: {error}"
|
|
299
|
+
) from error
|
|
300
|
+
os.close(directory_fd)
|
|
301
|
+
directory_fd = next_fd
|
|
302
|
+
pinned.parent_fd = directory_fd
|
|
303
|
+
pinned.parent_fd = directory_fd
|
|
304
|
+
pinned.missing_parent_parts = ()
|
|
305
|
+
pinned.materialized = True
|
|
306
|
+
if self._exists(pinned):
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f"{pinned.destination.label} appeared during secure preparation"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _exists(pinned: _PinnedDestination) -> bool:
|
|
313
|
+
try:
|
|
314
|
+
os.stat(
|
|
315
|
+
pinned.target_name,
|
|
316
|
+
dir_fd=pinned.parent_fd,
|
|
317
|
+
follow_symlinks=False,
|
|
318
|
+
)
|
|
319
|
+
return True
|
|
320
|
+
except FileNotFoundError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
def _get(self, destination: SecureDestination) -> _PinnedDestination:
|
|
324
|
+
path = lexical_absolute(destination.path)
|
|
325
|
+
try:
|
|
326
|
+
return self._pinned[path]
|
|
327
|
+
except KeyError as error:
|
|
328
|
+
raise RuntimeError(f"Destination was not pinned: {path}") from error
|
|
329
|
+
|
|
330
|
+
def initial_content(self, destination: SecureDestination) -> bytes | None:
|
|
331
|
+
"""Return the bytes captured through the pinned parent descriptor."""
|
|
332
|
+
return self._get(destination).snapshot.content
|
|
333
|
+
|
|
334
|
+
def atomic_write(
|
|
335
|
+
self,
|
|
336
|
+
destination: SecureDestination,
|
|
337
|
+
content: bytes,
|
|
338
|
+
mode: int | None = None,
|
|
339
|
+
) -> None:
|
|
340
|
+
self._atomic_write(destination, content, mode, enforce_snapshot=True)
|
|
341
|
+
|
|
342
|
+
def _atomic_write(
|
|
343
|
+
self,
|
|
344
|
+
destination: SecureDestination,
|
|
345
|
+
content: bytes,
|
|
346
|
+
mode: int | None,
|
|
347
|
+
*,
|
|
348
|
+
enforce_snapshot: bool,
|
|
349
|
+
) -> None:
|
|
350
|
+
pinned = self._get(destination)
|
|
351
|
+
if not pinned.materialized:
|
|
352
|
+
raise RuntimeError(
|
|
353
|
+
f"Destination parent was not materialized: {pinned.destination.path}"
|
|
354
|
+
)
|
|
355
|
+
if pinned.snapshot.existed:
|
|
356
|
+
file_mode = pinned.snapshot.mode
|
|
357
|
+
set_exact_mode = True
|
|
358
|
+
elif mode is None:
|
|
359
|
+
file_mode = 0o600
|
|
360
|
+
set_exact_mode = False
|
|
361
|
+
else:
|
|
362
|
+
file_mode = mode
|
|
363
|
+
set_exact_mode = True
|
|
364
|
+
temporary_name = f".{pinned.target_name}.{secrets.token_hex(8)}.tmp"
|
|
365
|
+
temporary_fd = -1
|
|
366
|
+
primary_error: BaseException | None = None
|
|
367
|
+
try:
|
|
368
|
+
temporary_fd = os.open(
|
|
369
|
+
temporary_name,
|
|
370
|
+
_WRITE_FLAGS,
|
|
371
|
+
file_mode,
|
|
372
|
+
dir_fd=pinned.parent_fd,
|
|
373
|
+
)
|
|
374
|
+
with os.fdopen(temporary_fd, "wb") as handle:
|
|
375
|
+
temporary_fd = -1
|
|
376
|
+
handle.write(content)
|
|
377
|
+
handle.flush()
|
|
378
|
+
if set_exact_mode:
|
|
379
|
+
os.fchmod(handle.fileno(), file_mode)
|
|
380
|
+
os.fsync(handle.fileno())
|
|
381
|
+
current = None
|
|
382
|
+
try:
|
|
383
|
+
current = os.stat(
|
|
384
|
+
pinned.target_name,
|
|
385
|
+
dir_fd=pinned.parent_fd,
|
|
386
|
+
follow_symlinks=False,
|
|
387
|
+
)
|
|
388
|
+
except FileNotFoundError:
|
|
389
|
+
pass
|
|
390
|
+
if current is not None and not stat.S_ISREG(current.st_mode):
|
|
391
|
+
raise RuntimeError(
|
|
392
|
+
f"Refusing replaced {pinned.destination.label} destination"
|
|
393
|
+
)
|
|
394
|
+
if enforce_snapshot:
|
|
395
|
+
if not pinned.snapshot.existed and current is not None:
|
|
396
|
+
raise RuntimeError(
|
|
397
|
+
f"Unexpected {pinned.destination.label} destination appeared"
|
|
398
|
+
)
|
|
399
|
+
if pinned.snapshot.existed and (
|
|
400
|
+
current is None
|
|
401
|
+
or current.st_dev != pinned.snapshot.device
|
|
402
|
+
or current.st_ino != pinned.snapshot.inode
|
|
403
|
+
):
|
|
404
|
+
raise RuntimeError(
|
|
405
|
+
f"Refusing changed {pinned.destination.label} destination"
|
|
406
|
+
)
|
|
407
|
+
self._touched.add(pinned.destination.path)
|
|
408
|
+
os.replace(
|
|
409
|
+
temporary_name,
|
|
410
|
+
pinned.target_name,
|
|
411
|
+
src_dir_fd=pinned.parent_fd,
|
|
412
|
+
dst_dir_fd=pinned.parent_fd,
|
|
413
|
+
)
|
|
414
|
+
os.fsync(pinned.parent_fd)
|
|
415
|
+
except BaseException as error:
|
|
416
|
+
primary_error = error
|
|
417
|
+
raise
|
|
418
|
+
finally:
|
|
419
|
+
cleanup_error: OSError | None = None
|
|
420
|
+
if temporary_fd >= 0:
|
|
421
|
+
try:
|
|
422
|
+
os.close(temporary_fd)
|
|
423
|
+
except OSError as error:
|
|
424
|
+
cleanup_error = error
|
|
425
|
+
try:
|
|
426
|
+
os.unlink(temporary_name, dir_fd=pinned.parent_fd)
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
pass
|
|
429
|
+
except OSError as error:
|
|
430
|
+
cleanup_error = cleanup_error or error
|
|
431
|
+
if primary_error is None and cleanup_error is not None:
|
|
432
|
+
raise cleanup_error
|
|
433
|
+
|
|
434
|
+
def unlink(self, destination: SecureDestination) -> None:
|
|
435
|
+
self._unlink(destination, enforce_snapshot=True)
|
|
436
|
+
|
|
437
|
+
def _unlink(
|
|
438
|
+
self,
|
|
439
|
+
destination: SecureDestination,
|
|
440
|
+
*,
|
|
441
|
+
enforce_snapshot: bool,
|
|
442
|
+
) -> None:
|
|
443
|
+
pinned = self._get(destination)
|
|
444
|
+
try:
|
|
445
|
+
metadata = os.stat(
|
|
446
|
+
pinned.target_name,
|
|
447
|
+
dir_fd=pinned.parent_fd,
|
|
448
|
+
follow_symlinks=False,
|
|
449
|
+
)
|
|
450
|
+
except FileNotFoundError:
|
|
451
|
+
return
|
|
452
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
453
|
+
raise RuntimeError(
|
|
454
|
+
f"Refusing to unlink non-regular {pinned.destination.label}"
|
|
455
|
+
)
|
|
456
|
+
if enforce_snapshot and (
|
|
457
|
+
not pinned.snapshot.existed
|
|
458
|
+
or metadata.st_dev != pinned.snapshot.device
|
|
459
|
+
or metadata.st_ino != pinned.snapshot.inode
|
|
460
|
+
):
|
|
461
|
+
raise RuntimeError(
|
|
462
|
+
f"Refusing changed {pinned.destination.label} destination"
|
|
463
|
+
)
|
|
464
|
+
self._touched.add(pinned.destination.path)
|
|
465
|
+
os.unlink(pinned.target_name, dir_fd=pinned.parent_fd)
|
|
466
|
+
os.fsync(pinned.parent_fd)
|
|
467
|
+
|
|
468
|
+
def rollback(self) -> None:
|
|
469
|
+
errors: list[Exception] = []
|
|
470
|
+
for pinned in reversed(list(self._pinned.values())):
|
|
471
|
+
if not pinned.materialized or pinned.destination.path not in self._touched:
|
|
472
|
+
continue
|
|
473
|
+
try:
|
|
474
|
+
if pinned.snapshot.existed:
|
|
475
|
+
assert pinned.snapshot.content is not None
|
|
476
|
+
self._atomic_write(
|
|
477
|
+
pinned.destination,
|
|
478
|
+
pinned.snapshot.content,
|
|
479
|
+
pinned.snapshot.mode,
|
|
480
|
+
enforce_snapshot=False,
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
self._unlink(pinned.destination, enforce_snapshot=False)
|
|
484
|
+
except Exception as error: # pragma: no cover - catastrophic I/O
|
|
485
|
+
errors.append(error)
|
|
486
|
+
for parent_fd, name in reversed(self._created_directories):
|
|
487
|
+
try:
|
|
488
|
+
os.rmdir(name, dir_fd=parent_fd)
|
|
489
|
+
os.fsync(parent_fd)
|
|
490
|
+
except Exception as error: # pragma: no cover - catastrophic I/O
|
|
491
|
+
errors.append(error)
|
|
492
|
+
if errors:
|
|
493
|
+
raise RuntimeError(f"Secure rollback was incomplete: {errors}")
|
|
494
|
+
|
|
495
|
+
def close(self) -> None:
|
|
496
|
+
for pinned in self._pinned.values():
|
|
497
|
+
if pinned.parent_fd >= 0:
|
|
498
|
+
try:
|
|
499
|
+
os.close(pinned.parent_fd)
|
|
500
|
+
except OSError:
|
|
501
|
+
pass
|
|
502
|
+
pinned.parent_fd = -1
|
|
503
|
+
for root_fd in self._root_fds.values():
|
|
504
|
+
try:
|
|
505
|
+
os.close(root_fd)
|
|
506
|
+
except OSError:
|
|
507
|
+
pass
|
|
508
|
+
self._root_fds.clear()
|
|
509
|
+
for parent_fd, _ in self._created_directories:
|
|
510
|
+
try:
|
|
511
|
+
os.close(parent_fd)
|
|
512
|
+
except OSError:
|
|
513
|
+
pass
|
|
514
|
+
self._created_directories.clear()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
T = TypeVar("T")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def run_secure_transaction(
|
|
521
|
+
destinations: list[SecureDestination],
|
|
522
|
+
mutation: Callable[[SecureTransaction], T],
|
|
523
|
+
) -> T:
|
|
524
|
+
"""Preflight all paths, apply via pinned fds, and rollback on failure."""
|
|
525
|
+
transaction = SecureTransaction(destinations)
|
|
526
|
+
try:
|
|
527
|
+
transaction.materialize_parents()
|
|
528
|
+
return mutation(transaction)
|
|
529
|
+
except BaseException as error:
|
|
530
|
+
try:
|
|
531
|
+
transaction.rollback()
|
|
532
|
+
except Exception as rollback_error:
|
|
533
|
+
raise RuntimeError(
|
|
534
|
+
f"Secure mutation failed and rollback was incomplete: {rollback_error}"
|
|
535
|
+
) from error
|
|
536
|
+
raise
|
|
537
|
+
finally:
|
|
538
|
+
transaction.close()
|