agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/paths.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""Cross-platform path naming and durable atomic filesystem primitives.
|
|
2
|
+
|
|
3
|
+
All vault component naming, collision allocation, and state-file replacement lives here.
|
|
4
|
+
Callers keep ordinary ``Path`` objects; ``long_path`` is applied only at the syscall
|
|
5
|
+
boundary because Windows' ``\\?\\`` paths are not safe to join or compare with pathlib.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import errno
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import unicodedata
|
|
19
|
+
from collections.abc import Collection, Iterator
|
|
20
|
+
from contextlib import suppress
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
WINDOWS = sys.platform == "win32"
|
|
24
|
+
DEFAULT_MAXLEN = 60
|
|
25
|
+
|
|
26
|
+
RESERVED = frozenset(
|
|
27
|
+
{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
|
|
28
|
+
| {f"COM{digit}" for digit in "123456789"}
|
|
29
|
+
| {f"COM{digit}" for digit in "¹²³"}
|
|
30
|
+
| {f"LPT{digit}" for digit in "123456789"}
|
|
31
|
+
| {f"LPT{digit}" for digit in "¹²³"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_RESERVED_CASEFOLD = frozenset(name.casefold() for name in RESERVED)
|
|
35
|
+
_RESERVED_CHARACTERS = re.compile(r'[<>:"/\\|?*]')
|
|
36
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
37
|
+
_SIMPLE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,15}$")
|
|
38
|
+
_WINDOWS_EXTENDED_PREFIX = "\\\\?\\"
|
|
39
|
+
_WINDOWS_UNC_PREFIX = "\\\\?\\UNC\\"
|
|
40
|
+
_UNSUPPORTED_DIRECTORY_FSYNC = frozenset(
|
|
41
|
+
{errno.EBADF, errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe_name(name: str, *, maxlen: int | None = None) -> str:
|
|
46
|
+
"""Return a deterministic, Windows-safe filename component.
|
|
47
|
+
|
|
48
|
+
The operation order is part of the vault contract: normalize, sanitize, collapse
|
|
49
|
+
whitespace, trim leading/trailing whitespace, truncate, then repair a reserved device
|
|
50
|
+
name. The same rules run on every platform so a vault has identical names after moving
|
|
51
|
+
between operating systems.
|
|
52
|
+
"""
|
|
53
|
+
budget = DEFAULT_MAXLEN if maxlen is None else _positive_budget(maxlen)
|
|
54
|
+
value = unicodedata.normalize("NFC", name)
|
|
55
|
+
value = _RESERVED_CHARACTERS.sub("_", value)
|
|
56
|
+
value = "".join("_" if unicodedata.category(char) in {"Cc", "Cf"} else char for char in value)
|
|
57
|
+
value = _WHITESPACE.sub(" ", value).lstrip().rstrip(" .")
|
|
58
|
+
if not value:
|
|
59
|
+
value = "untitled"
|
|
60
|
+
|
|
61
|
+
value = _truncate_component(value, budget)
|
|
62
|
+
value = value.rstrip(" .") or "untitled"
|
|
63
|
+
if len(value) > budget:
|
|
64
|
+
value = value[:budget].rstrip(" .") or "untitled"[:budget]
|
|
65
|
+
|
|
66
|
+
return _repair_reserved_name(value, budget)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def long_path(path: Path) -> Path:
|
|
70
|
+
"""Return a Windows extended-length path without following symlinks.
|
|
71
|
+
|
|
72
|
+
``resolve()`` is deliberately not used here. It follows a symlink before the caller
|
|
73
|
+
reaches the syscall, which can turn a safe identity check or an atomic replacement into an
|
|
74
|
+
operation on the symlink target. ``abspath`` normalizes the lexical path and preserves the
|
|
75
|
+
final component as the object the caller actually named.
|
|
76
|
+
"""
|
|
77
|
+
if not WINDOWS:
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
raw = os.fspath(path)
|
|
81
|
+
if raw.startswith(_WINDOWS_EXTENDED_PREFIX):
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
absolute_raw = os.path.abspath(raw)
|
|
85
|
+
if absolute_raw.startswith(_WINDOWS_EXTENDED_PREFIX):
|
|
86
|
+
return Path(absolute_raw)
|
|
87
|
+
if len(absolute_raw) <= 240:
|
|
88
|
+
return path
|
|
89
|
+
if absolute_raw.startswith(r"\\"):
|
|
90
|
+
return Path(_WINDOWS_UNC_PREFIX + absolute_raw[2:])
|
|
91
|
+
return Path(_WINDOWS_EXTENDED_PREFIX + absolute_raw)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_link(path: Path) -> bool:
|
|
95
|
+
"""Return whether ``path`` is a symlink or Windows reparse-point link."""
|
|
96
|
+
candidate = long_path(path)
|
|
97
|
+
try:
|
|
98
|
+
if candidate.is_symlink():
|
|
99
|
+
return True
|
|
100
|
+
file_stat = os.lstat(os.fspath(candidate))
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return False
|
|
103
|
+
return bool(getattr(file_stat, "st_file_attributes", 0) & 0x400)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def collides(destination: Path) -> bool:
|
|
107
|
+
"""Return whether a normalized, case-folded sibling already has this name."""
|
|
108
|
+
try:
|
|
109
|
+
with os.scandir(os.fspath(long_path(destination.parent))) as iterator:
|
|
110
|
+
entries = tuple(entry.name for entry in iterator)
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
wanted = _canonical_component(destination.name)
|
|
115
|
+
return any(_canonical_component(entry) == wanted for entry in entries)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def walk(root: Path) -> Iterator[Path]:
|
|
119
|
+
"""Yield ordinary paths from a tree while applying ``long_path`` at each scan boundary."""
|
|
120
|
+
|
|
121
|
+
def visit(directory: Path) -> Iterator[Path]:
|
|
122
|
+
with os.scandir(os.fspath(long_path(directory))) as iterator:
|
|
123
|
+
for entry in iterator:
|
|
124
|
+
child = directory / entry.name
|
|
125
|
+
yield child
|
|
126
|
+
if not is_link(child) and entry.is_dir(follow_symlinks=False):
|
|
127
|
+
yield from visit(child)
|
|
128
|
+
|
|
129
|
+
yield from visit(root)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def remove_tree(root: Path, *, ignore_errors: bool = False) -> None:
|
|
133
|
+
"""Remove a tree while applying ``long_path`` to each filesystem operation."""
|
|
134
|
+
try:
|
|
135
|
+
if is_link(root):
|
|
136
|
+
_remove_link(root)
|
|
137
|
+
return
|
|
138
|
+
if not long_path(root).is_dir():
|
|
139
|
+
os.unlink(os.fspath(long_path(root)))
|
|
140
|
+
return
|
|
141
|
+
candidates = list(walk(root))
|
|
142
|
+
for candidate in sorted(
|
|
143
|
+
candidates,
|
|
144
|
+
key=lambda value: len(value.relative_to(root).parts),
|
|
145
|
+
reverse=True,
|
|
146
|
+
):
|
|
147
|
+
if is_link(candidate):
|
|
148
|
+
_remove_link(candidate)
|
|
149
|
+
elif not long_path(candidate).is_dir():
|
|
150
|
+
os.unlink(os.fspath(long_path(candidate)))
|
|
151
|
+
else:
|
|
152
|
+
os.rmdir(os.fspath(long_path(candidate)))
|
|
153
|
+
os.rmdir(os.fspath(long_path(root)))
|
|
154
|
+
except OSError:
|
|
155
|
+
if not ignore_errors:
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def unique_path(destination: Path, *, reserved: Collection[Path] = ()) -> Path:
|
|
160
|
+
"""Return ``destination`` or the next available ``_2``/``_3`` sibling."""
|
|
161
|
+
reserved_names = {
|
|
162
|
+
_canonical_component(os.fspath(_plain_absolute(plain_path(path)))) for path in reserved
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def available(path: Path) -> bool:
|
|
166
|
+
name = _canonical_component(os.fspath(_plain_absolute(plain_path(path))))
|
|
167
|
+
return name not in reserved_names and not collides(path)
|
|
168
|
+
|
|
169
|
+
candidate_name = _truncate_component(destination.name, DEFAULT_MAXLEN).rstrip(" .")
|
|
170
|
+
if not candidate_name:
|
|
171
|
+
candidate_name = "untitled"
|
|
172
|
+
candidate = destination.with_name(candidate_name)
|
|
173
|
+
if available(candidate):
|
|
174
|
+
return candidate
|
|
175
|
+
|
|
176
|
+
stem, extension = _split_extension(candidate.name)
|
|
177
|
+
for number in range(2, 100_000):
|
|
178
|
+
suffix = f"_{number}"
|
|
179
|
+
available_stem_length = DEFAULT_MAXLEN - len(extension) - len(suffix)
|
|
180
|
+
if available_stem_length < 1:
|
|
181
|
+
raise ValueError("filename budget cannot fit a collision suffix")
|
|
182
|
+
candidate_name = f"{stem[:available_stem_length]}{suffix}{extension}"
|
|
183
|
+
candidate = destination.with_name(candidate_name)
|
|
184
|
+
if available(candidate):
|
|
185
|
+
return candidate
|
|
186
|
+
raise RuntimeError("could not allocate a unique path")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def reveal(path: Path) -> None:
|
|
190
|
+
"""Ask the platform file manager to reveal a path, swallowing launcher failures."""
|
|
191
|
+
try:
|
|
192
|
+
if WINDOWS:
|
|
193
|
+
command = ["explorer", os.fspath(long_path(path))]
|
|
194
|
+
elif sys.platform == "darwin":
|
|
195
|
+
command = ["open", os.fspath(long_path(path))]
|
|
196
|
+
else:
|
|
197
|
+
command = ["xdg-open", os.fspath(long_path(path))]
|
|
198
|
+
subprocess.Popen(command) # noqa: S603 - fixed executable, no shell
|
|
199
|
+
except OSError:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ensure_dir(directory: Path, *, root: Path | None = None, mode: int | None = None) -> None:
|
|
204
|
+
"""Create a directory tree at a syscall boundary.
|
|
205
|
+
|
|
206
|
+
When ``root`` is supplied, the complete lexical path is checked immediately before and after
|
|
207
|
+
the mkdir. Vault writers use this opt-in contract so a moved or malformed vault cannot turn a
|
|
208
|
+
course path into a write through a symlink/junction outside the vault. Configuration and
|
|
209
|
+
other non-vault callers retain the historical unconstrained behaviour by omitting ``root``.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
_validate_root_bound(directory, root)
|
|
213
|
+
if mode is not None:
|
|
214
|
+
if isinstance(mode, bool) or not isinstance(mode, int) or mode < 0:
|
|
215
|
+
raise ValueError("mode must be a non-negative integer")
|
|
216
|
+
long_path(directory).mkdir(mode=mode, parents=True, exist_ok=True)
|
|
217
|
+
else:
|
|
218
|
+
long_path(directory).mkdir(parents=True, exist_ok=True)
|
|
219
|
+
_validate_root_bound(directory, root)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def temporary_directory(parent: Path, *, prefix: str, root: Path | None = None) -> Path:
|
|
223
|
+
"""Create a sibling temporary directory at a syscall boundary."""
|
|
224
|
+
|
|
225
|
+
ensure_dir(parent, root=root)
|
|
226
|
+
raw_path = tempfile.mkdtemp(prefix=prefix, dir=os.fspath(long_path(parent)))
|
|
227
|
+
result = plain_path(Path(raw_path))
|
|
228
|
+
_validate_root_bound(result, root)
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def has_link_component(path: Path, *, root: Path | None = None) -> bool:
|
|
233
|
+
"""Return whether ``path`` has a link component inside the trusted root."""
|
|
234
|
+
|
|
235
|
+
absolute_path = Path(os.path.abspath(os.fspath(path)))
|
|
236
|
+
if root is None:
|
|
237
|
+
parts = absolute_path.parts
|
|
238
|
+
if not parts:
|
|
239
|
+
return False
|
|
240
|
+
trusted_root = Path(parts[0])
|
|
241
|
+
relative_parts = parts[1:]
|
|
242
|
+
else:
|
|
243
|
+
trusted_root = Path(os.path.abspath(os.fspath(root)))
|
|
244
|
+
try:
|
|
245
|
+
relative_parts = absolute_path.relative_to(trusted_root).parts
|
|
246
|
+
except ValueError:
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
candidate = trusted_root
|
|
250
|
+
if is_link(candidate):
|
|
251
|
+
return True
|
|
252
|
+
for part in relative_parts:
|
|
253
|
+
if not long_path(candidate).exists():
|
|
254
|
+
return False
|
|
255
|
+
candidate = candidate / part
|
|
256
|
+
if is_link(candidate):
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def symlink_dir(source: Path, destination: Path) -> None:
|
|
262
|
+
"""Create an opt-in directory symlink at a syscall boundary."""
|
|
263
|
+
|
|
264
|
+
ensure_dir(destination.parent)
|
|
265
|
+
os.symlink(
|
|
266
|
+
os.fspath(long_path(source)),
|
|
267
|
+
os.fspath(long_path(destination)),
|
|
268
|
+
target_is_directory=True,
|
|
269
|
+
)
|
|
270
|
+
_fsync_directory(destination.parent)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def replace_link(
|
|
274
|
+
destination: Path,
|
|
275
|
+
source: Path,
|
|
276
|
+
*,
|
|
277
|
+
root: Path | None = None,
|
|
278
|
+
retries: int = 5,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Atomically install a directory link while retaining the old object on failure.
|
|
281
|
+
|
|
282
|
+
A directory cannot be replaced in place by ``os.replace`` on every supported platform.
|
|
283
|
+
The old object is therefore moved to a sibling backup before the staged link is installed;
|
|
284
|
+
a failed install rolls that backup back into place. ``source`` is trusted by the caller and
|
|
285
|
+
must be an ordinary directory, while ``root`` protects the destination parent.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
_validate_retries(retries)
|
|
289
|
+
if not long_path(source).is_dir() or is_link(source):
|
|
290
|
+
raise ValueError("link source must be an ordinary directory")
|
|
291
|
+
if root is not None and has_link_component(destination.parent, root=root):
|
|
292
|
+
raise ValueError("link destination path contains a link component")
|
|
293
|
+
_validate_within_root(destination, root)
|
|
294
|
+
|
|
295
|
+
# Revalidate immediately before reserving the sibling link: a linked parent introduced after
|
|
296
|
+
# the initial check must not redirect the temporary object outside ``root``.
|
|
297
|
+
_validate_root_bound(destination.parent, root)
|
|
298
|
+
temporary = _create_temporary_link(destination, source)
|
|
299
|
+
try:
|
|
300
|
+
_validate_root_bound(temporary.parent, root)
|
|
301
|
+
_validate_root_bound(destination.parent, root)
|
|
302
|
+
backup = _backup_path(destination)
|
|
303
|
+
had_destination = long_path(destination).exists() or is_link(destination)
|
|
304
|
+
if had_destination:
|
|
305
|
+
_replace_with_retries(destination, backup, retries)
|
|
306
|
+
try:
|
|
307
|
+
_replace_with_retries(temporary, destination, retries)
|
|
308
|
+
except OSError:
|
|
309
|
+
if had_destination and not (long_path(destination).exists() or is_link(destination)):
|
|
310
|
+
_replace_with_retries(backup, destination, retries)
|
|
311
|
+
raise
|
|
312
|
+
if had_destination:
|
|
313
|
+
remove_tree(backup, ignore_errors=True)
|
|
314
|
+
finally:
|
|
315
|
+
_remove_quietly(temporary)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def atomic_write_text(
|
|
319
|
+
destination: Path, text: str, *, root: Path | None = None, retries: int = 5
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Write UTF-8 LF text and atomically install it at ``destination``."""
|
|
322
|
+
_validate_retries(retries)
|
|
323
|
+
_validate_root_bound(destination, root)
|
|
324
|
+
# ``_create_temporary`` performs its own filesystem operation. Check the parent again at that
|
|
325
|
+
# boundary rather than relying only on the earlier destination validation.
|
|
326
|
+
_validate_root_bound(destination.parent, root)
|
|
327
|
+
temporary, file_descriptor = _create_temporary_file(destination, suffix=".tmp")
|
|
328
|
+
# ``mkstemp`` is a separate filesystem operation from the destination check above. Recheck
|
|
329
|
+
# the complete temporary path before opening it so a parent swapped to a link between those
|
|
330
|
+
# operations fails closed instead of writing through the replacement.
|
|
331
|
+
# This temporary contains content generated by this call and is cheap to recreate;
|
|
332
|
+
# unlike atomic_install_temp, it is correct to remove it when installation fails.
|
|
333
|
+
descriptor_open = True
|
|
334
|
+
try:
|
|
335
|
+
_validate_root_bound(temporary, root)
|
|
336
|
+
with os.fdopen(file_descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
337
|
+
descriptor_open = False
|
|
338
|
+
handle.write(text)
|
|
339
|
+
handle.flush()
|
|
340
|
+
os.fsync(handle.fileno())
|
|
341
|
+
_tighten_permissions(temporary)
|
|
342
|
+
_validate_root_bound(destination, root)
|
|
343
|
+
_replace_with_retries(temporary, destination, retries)
|
|
344
|
+
finally:
|
|
345
|
+
if descriptor_open:
|
|
346
|
+
with suppress(OSError):
|
|
347
|
+
os.close(file_descriptor)
|
|
348
|
+
_remove_quietly(temporary)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def atomic_write_bytes(
|
|
352
|
+
destination: Path, data: bytes, *, root: Path | None = None, retries: int = 5
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Write exact bytes and atomically install them at ``destination``."""
|
|
355
|
+
_validate_retries(retries)
|
|
356
|
+
_validate_root_bound(destination, root)
|
|
357
|
+
_validate_root_bound(destination.parent, root)
|
|
358
|
+
temporary, file_descriptor = _create_temporary_file(destination, suffix=".tmp")
|
|
359
|
+
# This temporary contains content generated by this call and is cheap to recreate;
|
|
360
|
+
# unlike atomic_install_temp, it is correct to remove it when installation fails.
|
|
361
|
+
descriptor_open = True
|
|
362
|
+
try:
|
|
363
|
+
_validate_root_bound(temporary, root)
|
|
364
|
+
with os.fdopen(file_descriptor, "wb") as handle:
|
|
365
|
+
descriptor_open = False
|
|
366
|
+
handle.write(data)
|
|
367
|
+
handle.flush()
|
|
368
|
+
os.fsync(handle.fileno())
|
|
369
|
+
_tighten_permissions(temporary)
|
|
370
|
+
_validate_root_bound(destination, root)
|
|
371
|
+
_replace_with_retries(temporary, destination, retries)
|
|
372
|
+
finally:
|
|
373
|
+
if descriptor_open:
|
|
374
|
+
with suppress(OSError):
|
|
375
|
+
os.close(file_descriptor)
|
|
376
|
+
_remove_quietly(temporary)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def atomic_install_temp(
|
|
380
|
+
destination: Path,
|
|
381
|
+
temporary: Path,
|
|
382
|
+
*,
|
|
383
|
+
root: Path | None = None,
|
|
384
|
+
retries: int = 5,
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Fsync and atomically install a validated sibling download ``.part`` file."""
|
|
387
|
+
_validate_retries(retries)
|
|
388
|
+
_validate_root_bound(destination, root)
|
|
389
|
+
_validate_root_bound(temporary, root)
|
|
390
|
+
_validate_part_file(destination, temporary)
|
|
391
|
+
# This .part is a completed download, so retain it when fsync or installation fails;
|
|
392
|
+
# the next sync can retry the expensive download instead of fetching it again.
|
|
393
|
+
_fsync_file(temporary)
|
|
394
|
+
_tighten_permissions(temporary)
|
|
395
|
+
_validate_root_bound(destination, root)
|
|
396
|
+
_validate_root_bound(temporary, root)
|
|
397
|
+
_replace_with_retries(temporary, destination, retries)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def replace_tree(
|
|
401
|
+
destination: Path, staged: Path, *, root: Path | None = None, retries: int = 5
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Atomically move a staged tree into place and restore the old tree on failure."""
|
|
404
|
+
|
|
405
|
+
_validate_retries(retries)
|
|
406
|
+
if _plain_absolute(staged.parent) != _plain_absolute(destination.parent):
|
|
407
|
+
raise ValueError("staged tree must be a sibling of destination")
|
|
408
|
+
# Replacing a final destination symlink is an intentional operation for the skill installer;
|
|
409
|
+
# only its parent and the staged tree must remain inside the trusted root.
|
|
410
|
+
_validate_root_bound(destination.parent, root)
|
|
411
|
+
_validate_within_root(destination, root)
|
|
412
|
+
_validate_root_bound(staged, root)
|
|
413
|
+
# The checks above protect the caller's path construction; repeat them at the replacement
|
|
414
|
+
# boundary after backup allocation so a parent swapped during staging fails closed.
|
|
415
|
+
_validate_root_bound(destination.parent, root)
|
|
416
|
+
_validate_root_bound(staged, root)
|
|
417
|
+
backup = _backup_path(destination)
|
|
418
|
+
had_destination = long_path(destination).exists() or is_link(destination)
|
|
419
|
+
if had_destination:
|
|
420
|
+
_validate_root_bound(destination.parent, root)
|
|
421
|
+
_validate_root_bound(staged, root)
|
|
422
|
+
_replace_with_retries(destination, backup, retries)
|
|
423
|
+
try:
|
|
424
|
+
_validate_root_bound(destination.parent, root)
|
|
425
|
+
_validate_root_bound(staged, root)
|
|
426
|
+
_replace_with_retries(staged, destination, retries)
|
|
427
|
+
except OSError:
|
|
428
|
+
if had_destination and not long_path(destination).exists():
|
|
429
|
+
_replace_with_retries(backup, destination, retries)
|
|
430
|
+
raise
|
|
431
|
+
if had_destination:
|
|
432
|
+
remove_tree(backup, ignore_errors=True)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def rel_posix(path: Path, root: Path) -> str:
|
|
436
|
+
"""Return a non-escaping vault-relative path using forward slashes."""
|
|
437
|
+
resolved_path = path.resolve()
|
|
438
|
+
resolved_root = root.resolve()
|
|
439
|
+
try:
|
|
440
|
+
relative = resolved_path.relative_to(resolved_root)
|
|
441
|
+
except ValueError as exc:
|
|
442
|
+
raise ValueError("path is not relative to root") from exc
|
|
443
|
+
return relative.as_posix()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _positive_budget(value: int) -> int:
|
|
447
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
448
|
+
raise ValueError("maxlen must be a positive integer")
|
|
449
|
+
return value
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _truncate_component(value: str, budget: int) -> str:
|
|
453
|
+
match = _SIMPLE_EXTENSION.search(value)
|
|
454
|
+
if match is None:
|
|
455
|
+
return value[:budget]
|
|
456
|
+
|
|
457
|
+
extension = match.group(0)
|
|
458
|
+
stem = value[: -len(extension)]
|
|
459
|
+
if stem and budget > len(extension):
|
|
460
|
+
return stem[: budget - len(extension)] + extension
|
|
461
|
+
return value[:budget]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _repair_reserved_name(value: str, budget: int) -> str:
|
|
465
|
+
reserved_stem = value.split(".", 1)[0]
|
|
466
|
+
if reserved_stem.casefold() not in _RESERVED_CASEFOLD:
|
|
467
|
+
return value[:budget]
|
|
468
|
+
|
|
469
|
+
remainder = value[len(reserved_stem) :]
|
|
470
|
+
if len(reserved_stem) >= budget:
|
|
471
|
+
if budget == 1:
|
|
472
|
+
return "_"
|
|
473
|
+
return reserved_stem[: budget - 1] + "_"
|
|
474
|
+
|
|
475
|
+
repaired = f"{reserved_stem}_{remainder[: budget - len(reserved_stem) - 1]}".rstrip(" .")
|
|
476
|
+
if not repaired:
|
|
477
|
+
return "untitled"[:budget]
|
|
478
|
+
return repaired[:budget]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _split_extension(name: str) -> tuple[str, str]:
|
|
482
|
+
first_dot = name.find(".")
|
|
483
|
+
if first_dot <= 0:
|
|
484
|
+
return name, ""
|
|
485
|
+
extension_match = _SIMPLE_EXTENSION.search(name)
|
|
486
|
+
if extension_match is None:
|
|
487
|
+
return name, ""
|
|
488
|
+
return name[: -len(extension_match.group(0))], extension_match.group(0)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _canonical_component(name: str) -> str:
|
|
492
|
+
return unicodedata.normalize("NFC", name).casefold()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _validate_retries(retries: int) -> None:
|
|
496
|
+
if isinstance(retries, bool) or not isinstance(retries, int) or retries <= 0:
|
|
497
|
+
raise ValueError("retries must be a positive integer")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _create_temporary(destination: Path, *, suffix: str) -> Path:
|
|
501
|
+
path, file_descriptor = _create_temporary_file(destination, suffix=suffix)
|
|
502
|
+
os.close(file_descriptor)
|
|
503
|
+
return path
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _create_temporary_file(destination: Path, *, suffix: str) -> tuple[Path, int]:
|
|
507
|
+
file_descriptor, raw_path = tempfile.mkstemp(
|
|
508
|
+
prefix=f".{destination.name}.",
|
|
509
|
+
suffix=suffix,
|
|
510
|
+
dir=os.fspath(long_path(destination.parent)),
|
|
511
|
+
)
|
|
512
|
+
return plain_path(Path(raw_path)), file_descriptor
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _create_temporary_link(destination: Path, source: Path) -> Path:
|
|
516
|
+
"""Create a private sibling link without exposing a partially written destination."""
|
|
517
|
+
|
|
518
|
+
temporary = _create_temporary(destination, suffix=".link")
|
|
519
|
+
try:
|
|
520
|
+
os.unlink(os.fspath(long_path(temporary)))
|
|
521
|
+
os.symlink(
|
|
522
|
+
os.fspath(long_path(source)),
|
|
523
|
+
os.fspath(long_path(temporary)),
|
|
524
|
+
target_is_directory=True,
|
|
525
|
+
)
|
|
526
|
+
except BaseException:
|
|
527
|
+
# Do not unlink an unexpected object that another local process inserted after the
|
|
528
|
+
# placeholder was removed; the hidden name is harmless debris and data safety wins.
|
|
529
|
+
if is_link(temporary):
|
|
530
|
+
_remove_quietly(temporary)
|
|
531
|
+
raise
|
|
532
|
+
return temporary
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _backup_path(destination: Path) -> Path:
|
|
536
|
+
for index in range(1, 100_000):
|
|
537
|
+
backup = destination.parent / f".{destination.name}.backup.{index}"
|
|
538
|
+
if not long_path(backup).exists() and not is_link(backup):
|
|
539
|
+
return backup
|
|
540
|
+
raise RuntimeError("could not allocate a backup path")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _plain_absolute(path: Path) -> Path:
|
|
544
|
+
return Path(os.path.abspath(os.fspath(path)))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _validate_root_bound(path: Path, root: Path | None) -> None:
|
|
548
|
+
"""Reject linked components for an operation explicitly bound to ``root``."""
|
|
549
|
+
|
|
550
|
+
if root is None:
|
|
551
|
+
return
|
|
552
|
+
if has_link_component(path, root=root):
|
|
553
|
+
raise ValueError("path contains a link component outside the trusted root")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _validate_within_root(path: Path, root: Path | None) -> None:
|
|
557
|
+
"""Reject a lexical path escape while allowing an intentional final link."""
|
|
558
|
+
|
|
559
|
+
if root is None:
|
|
560
|
+
return
|
|
561
|
+
absolute = Path(os.path.abspath(os.fspath(path)))
|
|
562
|
+
trusted = Path(os.path.abspath(os.fspath(root)))
|
|
563
|
+
try:
|
|
564
|
+
absolute.relative_to(trusted)
|
|
565
|
+
except ValueError as exc:
|
|
566
|
+
raise ValueError("path is outside the trusted root") from exc
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def plain_path(path: Path) -> Path:
|
|
570
|
+
"""Remove an inherited Windows extended prefix before a path returns to application code."""
|
|
571
|
+
if not WINDOWS:
|
|
572
|
+
return path
|
|
573
|
+
raw = os.fspath(path)
|
|
574
|
+
if raw.startswith(_WINDOWS_UNC_PREFIX):
|
|
575
|
+
return Path("\\\\" + raw[len(_WINDOWS_UNC_PREFIX) :])
|
|
576
|
+
if raw.startswith(_WINDOWS_EXTENDED_PREFIX):
|
|
577
|
+
return Path(raw[4:])
|
|
578
|
+
return path
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _tighten_permissions(path: Path) -> None:
|
|
582
|
+
if not WINDOWS:
|
|
583
|
+
os.chmod(os.fspath(long_path(path)), 0o600)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _fsync_file(path: Path) -> None:
|
|
587
|
+
# Windows requires a writable handle for fsync; POSIX accepts a read-only one.
|
|
588
|
+
mode = "r+b" if WINDOWS else "rb"
|
|
589
|
+
with open(os.fspath(long_path(path)), mode) as handle:
|
|
590
|
+
os.fsync(handle.fileno())
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _fsync_directory(directory: Path) -> None:
|
|
594
|
+
if WINDOWS:
|
|
595
|
+
return
|
|
596
|
+
try:
|
|
597
|
+
file_descriptor = os.open(os.fspath(long_path(directory)), os.O_RDONLY)
|
|
598
|
+
except OSError as exc:
|
|
599
|
+
if exc.errno in _UNSUPPORTED_DIRECTORY_FSYNC:
|
|
600
|
+
return
|
|
601
|
+
raise
|
|
602
|
+
try:
|
|
603
|
+
try:
|
|
604
|
+
os.fsync(file_descriptor)
|
|
605
|
+
except OSError as exc:
|
|
606
|
+
if exc.errno not in _UNSUPPORTED_DIRECTORY_FSYNC:
|
|
607
|
+
raise
|
|
608
|
+
finally:
|
|
609
|
+
os.close(file_descriptor)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _replace_with_retries(temporary: Path, destination: Path, retries: int) -> None:
|
|
613
|
+
for attempt in range(retries):
|
|
614
|
+
try:
|
|
615
|
+
os.replace(
|
|
616
|
+
os.fspath(long_path(temporary)),
|
|
617
|
+
os.fspath(long_path(destination)),
|
|
618
|
+
)
|
|
619
|
+
except PermissionError:
|
|
620
|
+
if attempt == retries - 1:
|
|
621
|
+
raise
|
|
622
|
+
time.sleep(0.01 * (2**attempt))
|
|
623
|
+
else:
|
|
624
|
+
_fsync_directory(destination.parent)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _validate_part_file(destination: Path, temporary: Path) -> None:
|
|
629
|
+
if temporary.parent.resolve() != destination.parent.resolve():
|
|
630
|
+
raise ValueError("temporary file must be a sibling of destination")
|
|
631
|
+
if not temporary.name.endswith(".part"):
|
|
632
|
+
raise ValueError("temporary download must end with .part")
|
|
633
|
+
try:
|
|
634
|
+
file_stat = os.lstat(os.fspath(long_path(temporary)))
|
|
635
|
+
except FileNotFoundError:
|
|
636
|
+
raise
|
|
637
|
+
if is_link(temporary) or stat.S_ISLNK(file_stat.st_mode):
|
|
638
|
+
raise ValueError("temporary download must not be a symlink")
|
|
639
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
640
|
+
raise ValueError("temporary download must be a regular file")
|
|
641
|
+
if getattr(file_stat, "st_nlink", 1) != 1:
|
|
642
|
+
raise ValueError("temporary download must not be a hard link")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _remove_quietly(path: Path) -> None:
|
|
646
|
+
try:
|
|
647
|
+
os.unlink(os.fspath(long_path(path)))
|
|
648
|
+
except OSError:
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _remove_link(path: Path) -> None:
|
|
653
|
+
"""Remove a link itself, using directory removal for Windows junctions."""
|
|
654
|
+
if WINDOWS and long_path(path).is_dir():
|
|
655
|
+
os.rmdir(os.fspath(long_path(path)))
|
|
656
|
+
else:
|
|
657
|
+
os.unlink(os.fspath(long_path(path)))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
__all__ = [
|
|
661
|
+
"DEFAULT_MAXLEN",
|
|
662
|
+
"RESERVED",
|
|
663
|
+
"WINDOWS",
|
|
664
|
+
"atomic_install_temp",
|
|
665
|
+
"atomic_write_bytes",
|
|
666
|
+
"atomic_write_text",
|
|
667
|
+
"collides",
|
|
668
|
+
"ensure_dir",
|
|
669
|
+
"has_link_component",
|
|
670
|
+
"is_link",
|
|
671
|
+
"long_path",
|
|
672
|
+
"plain_path",
|
|
673
|
+
"remove_tree",
|
|
674
|
+
"replace_link",
|
|
675
|
+
"replace_tree",
|
|
676
|
+
"rel_posix",
|
|
677
|
+
"walk",
|
|
678
|
+
"reveal",
|
|
679
|
+
"safe_name",
|
|
680
|
+
"symlink_dir",
|
|
681
|
+
"temporary_directory",
|
|
682
|
+
"unique_path",
|
|
683
|
+
]
|