froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
"""Cross-platform process primitives.
|
|
2
|
+
|
|
3
|
+
The pid kill/liveness primitives now live behind the :class:`~froid_loop.process_host.ProcessHost`
|
|
4
|
+
seam; ``terminate_pid``/``pid_alive`` remain here as thin back-compat shims that
|
|
5
|
+
delegate to it. ``detach_kwargs`` stays a real implementation — it is spawn
|
|
6
|
+
configuration, not a process-lifecycle primitive, so it does not belong on the
|
|
7
|
+
host. On Linux/macOS — and WSL, which *is* Linux — these preserve today's exact
|
|
8
|
+
behavior. The file-replace and segment helpers below (``atomic_replace``,
|
|
9
|
+
``atomic_write_text``, ``atomic_write_bytes``, ``safe_segment``,
|
|
10
|
+
``safe_ref_segment``) are exercised by the platform tests; the pid kill/liveness
|
|
11
|
+
Windows branch degrades gracefully and is not yet exercised.
|
|
12
|
+
|
|
13
|
+
``safe_segment`` and ``safe_ref_segment`` share a contract but not a rule set: the
|
|
14
|
+
first coerces a Windows *filename* segment, the second a *git ref* component, and
|
|
15
|
+
neither alphabet contains the other (``CON`` is a legal ref and an illegal filename;
|
|
16
|
+
``a..b`` is the reverse). Consumers that derive both a directory and a branch from
|
|
17
|
+
the same key must run both.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import errno
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import stat
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
from contextlib import contextmanager, suppress
|
|
34
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
35
|
+
from typing import Callable, Iterator
|
|
36
|
+
|
|
37
|
+
from .process_host import get_process_host
|
|
38
|
+
|
|
39
|
+
# Windows-only: os.replace (MoveFileExW) fails with ERROR_ACCESS_DENIED (5) or
|
|
40
|
+
# ERROR_SHARING_VIOLATION (32) when a concurrent reader holds a handle on the
|
|
41
|
+
# target — Python's open() grants no FILE_SHARE_DELETE, so renaming over the open
|
|
42
|
+
# file is denied. Readers hold their handle briefly, so a jittered backoff clears
|
|
43
|
+
# it; an anti-virus / indexer touch can hold longer, hence the ~5 s worst case.
|
|
44
|
+
# POSIX rename-over-open never raises this, so the retry stays win32-gated.
|
|
45
|
+
_REPLACE_ATTEMPTS = 12
|
|
46
|
+
_REPLACE_BASE_S = 0.02
|
|
47
|
+
_REPLACE_CAP_S = 0.7
|
|
48
|
+
|
|
49
|
+
# Windows-only: the "name does not fit" errno is not ENAMETOOLONG. CPython's
|
|
50
|
+
# PC/errmap.h maps ERROR_FILENAME_EXCED_RANGE (206) to ENOENT and does not map
|
|
51
|
+
# ERROR_BUFFER_OVERFLOW (111) at all — it falls to the EINVAL default — so
|
|
52
|
+
# ENAMETOOLONG is effectively unreachable there and only .winerror tells.
|
|
53
|
+
# ERROR_INVALID_NAME (123) is deliberately NOT here: it also fires for a name
|
|
54
|
+
# holding characters win32 forbids outright, which no shorter prefix fixes.
|
|
55
|
+
_WINERROR_FILENAME_EXCED_RANGE = 206
|
|
56
|
+
|
|
57
|
+
# Reserved on Windows regardless of extension: CON.txt is as illegal as CON. The
|
|
58
|
+
# COM0/LPT0 and superscript (COM¹/COM²/COM³) forms are reserved by the same rule,
|
|
59
|
+
# as are the console device names CONIN$/CONOUT$.
|
|
60
|
+
_RESERVED_BASENAMES = frozenset(
|
|
61
|
+
{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
|
|
62
|
+
| {f"COM{i}" for i in range(10)}
|
|
63
|
+
| {f"LPT{i}" for i in range(10)}
|
|
64
|
+
| {f"COM{s}" for s in "¹²³"}
|
|
65
|
+
| {f"LPT{s}" for s in "¹²³"}
|
|
66
|
+
)
|
|
67
|
+
_ILLEGAL_SEGMENT_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
68
|
+
MAX_SEGMENT = 120 # keep segment (incl. any collision suffix) well under the 255 limit
|
|
69
|
+
|
|
70
|
+
# git-check-ref-format(1) rejects these anywhere in a ref component: ASCII control
|
|
71
|
+
# chars and space (\x00-\x20), DEL, and `~ ^ : ? * [ \`. `/` is added because it
|
|
72
|
+
# would split one component into two. `]`, `-`, `<`, `>`, `"` and `|` are all legal
|
|
73
|
+
# in a ref and deliberately absent — this is not _ILLEGAL_SEGMENT_CHARS.
|
|
74
|
+
_ILLEGAL_REF_CHARS = re.compile(r"[\x00-\x20\x7f~^:?*\[\\/]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def terminate_pid(pid: int) -> None:
|
|
78
|
+
"""Politely terminate ``pid``. Back-compat shim over
|
|
79
|
+
:meth:`ProcessHost.terminate` — prefer ``get_process_host().terminate(pid)``
|
|
80
|
+
in new code."""
|
|
81
|
+
get_process_host().terminate(pid)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pid_alive(pid: int) -> bool:
|
|
85
|
+
"""Read-only liveness check for ``pid``. Back-compat shim over
|
|
86
|
+
:meth:`ProcessHost.is_alive` — prefer ``get_process_host().is_alive(pid)`` in
|
|
87
|
+
new code."""
|
|
88
|
+
return get_process_host().is_alive(pid)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detach_kwargs() -> dict[str, object]:
|
|
92
|
+
"""``Popen`` kwargs that detach a child so it outlives its launcher.
|
|
93
|
+
|
|
94
|
+
POSIX uses ``start_new_session``; Windows uses a new process group via
|
|
95
|
+
``creationflags`` (not exercised yet)."""
|
|
96
|
+
if sys.platform == "win32":
|
|
97
|
+
# portability: start_new_session is POSIX-only; CREATE_NEW_PROCESS_GROUP
|
|
98
|
+
# is the Windows analogue.
|
|
99
|
+
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
100
|
+
return {"start_new_session": True} # portability: POSIX detach kwarg; Windows branch above
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_absolute_path(value: str | Path) -> bool:
|
|
104
|
+
"""True if ``value`` is rooted or drive-qualified in *either* POSIX or Windows
|
|
105
|
+
terms — i.e. not safe as a path *inside* the project.
|
|
106
|
+
|
|
107
|
+
Purpose-built for the "must be project-relative" guards (profile/manifest):
|
|
108
|
+
``Path.is_absolute()`` is platform-dependent, so on Windows a POSIX-absolute
|
|
109
|
+
``/etc/passwd`` reads as *not* absolute and slips a guard built on it. This
|
|
110
|
+
rejects, on every platform: a POSIX root (``/etc/passwd``), a Windows root or
|
|
111
|
+
drive-absolute path (``\\x``, ``C:\\x``), *and* a Windows drive-*relative* path
|
|
112
|
+
(``C:foo`` — technically relative, but still drive-qualified and never a valid
|
|
113
|
+
in-project path). Strictly broader than "absolute"; the extra rejection of
|
|
114
|
+
``C:foo`` is intentional for these guards. Pair with :func:`has_parent_ref` to
|
|
115
|
+
also reject ``..`` escapes."""
|
|
116
|
+
text = str(value)
|
|
117
|
+
win = PureWindowsPath(text)
|
|
118
|
+
return PurePosixPath(text).is_absolute() or bool(win.drive or win.root)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def has_parent_ref(value: str | Path) -> bool:
|
|
122
|
+
"""True if ``value`` contains a ``..`` segment in *either* POSIX or Windows
|
|
123
|
+
terms. ``is_absolute_path`` rejects absolute escapes but not relative ones:
|
|
124
|
+
``../../etc`` is not absolute yet still climbs out of the project tree. Pair
|
|
125
|
+
the two for a complete "must stay inside the project" guard."""
|
|
126
|
+
text = str(value)
|
|
127
|
+
return ".." in PurePosixPath(text).parts or ".." in PureWindowsPath(text).parts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def names_tree_root(value: str | Path) -> bool:
|
|
131
|
+
"""True if ``value`` names the tree it is relative to rather than anything
|
|
132
|
+
*inside* it: ``""``, ``"."``, ``"./"``, ``"./."`` all normalize to the root.
|
|
133
|
+
|
|
134
|
+
The third member of the "must be a path inside the project" family, and the
|
|
135
|
+
one a `not value` emptiness check misses. It exists because these guards feed
|
|
136
|
+
`provision_worktree`'s seed loop, where a root-naming entry resolves ``src``
|
|
137
|
+
to the repo root and ``dst`` to the worktree, both of which pass the loop's
|
|
138
|
+
``is_relative_to`` containment checks — a path is relative to itself. Measured:
|
|
139
|
+
``""`` and ``"."`` produce a byte-identical ``(src, raw, dst)`` triple there,
|
|
140
|
+
so a guard rejecting only the first is a guard against one spelling.
|
|
141
|
+
|
|
142
|
+
Both flavours are checked for the same reason :func:`is_absolute_path` checks
|
|
143
|
+
both: ``".\\"`` is a root ref Windows normalizes away and POSIX parsing keeps
|
|
144
|
+
as an ordinary one-segment name. Pair with the other two for a complete
|
|
145
|
+
"must stay inside the project, and must name something in it" guard.
|
|
146
|
+
|
|
147
|
+
The dot/space spellings are the same asymmetry one layer down. Win32 path
|
|
148
|
+
normalization strips *every* trailing period and space from a path's final
|
|
149
|
+
component, so ``". "``, ``".. "``, ``"..."`` and even ``" "`` all name the
|
|
150
|
+
containing directory there, while both pure flavours keep them as ordinary
|
|
151
|
+
one-segment names (pathlib never applies that trim — only ``resolve()``, by
|
|
152
|
+
asking the OS, does). A component made solely of periods and spaces is
|
|
153
|
+
therefore root-naming, and that is where *this* predicate's rule stops:
|
|
154
|
+
``"foo. "`` strips to ``"foo"``, names a child, and is accepted here. It is
|
|
155
|
+
still refused, by :func:`names_win32_alias`, on the ground this predicate does
|
|
156
|
+
not speak to: the trim leaves ``"foo. "`` inside the tree, so containment has
|
|
157
|
+
nothing to object to, but it does not name the same path on Windows as it does
|
|
158
|
+
on POSIX, and that determinism rule is the fourth member's.
|
|
159
|
+
|
|
160
|
+
``".. "`` lands here rather than in :func:`has_parent_ref` because the trailing
|
|
161
|
+
space stops it matching the ``..`` relative component, so Win32 trims it to
|
|
162
|
+
empty instead of climbing — it names the root, not the parent. That reading is
|
|
163
|
+
Wine's conformance suite and Project Zero's write-up; Microsoft's own docs are
|
|
164
|
+
ambiguous on the trim-vs-relative-component ordering. Nothing rests on
|
|
165
|
+
resolving it: under the other reading ``".. "`` escapes the tree, and the
|
|
166
|
+
call sites that pair the two guards reject it either way. Plain ``".."`` is
|
|
167
|
+
unchanged and stays :func:`has_parent_ref`'s job."""
|
|
168
|
+
text = str(value)
|
|
169
|
+
if PurePosixPath(text) == PurePosixPath(".") or PureWindowsPath(text) == PureWindowsPath("."):
|
|
170
|
+
return True
|
|
171
|
+
# `/` separates on both platforms, `\` only on Windows — split on both so a
|
|
172
|
+
# value is judged by the same components Win32 would see.
|
|
173
|
+
parts = [part for part in text.replace("\\", "/").split("/") if part]
|
|
174
|
+
return bool(parts) and all(part.strip(" .") == "" and part != ".." for part in parts)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def names_win32_alias(value: str | Path) -> bool:
|
|
178
|
+
"""True if any component of ``value`` names something other than itself on
|
|
179
|
+
Win32 — a reserved device name, or a name whose trailing periods and spaces
|
|
180
|
+
Win32 trims away before the path ever reaches the filesystem.
|
|
181
|
+
|
|
182
|
+
The fourth member of the "must be a path inside the project" family, and the
|
|
183
|
+
only one about *determinism* rather than containment. The other three refuse a
|
|
184
|
+
value that leaves the tree; this one refuses a value that stays inside it and
|
|
185
|
+
still names a *different* path on Windows than it does on POSIX.
|
|
186
|
+
``skill_tree = "NUL"`` is project-relative by every measure the other three
|
|
187
|
+
apply, and on Windows it is a device rather than a directory. Two rules, both
|
|
188
|
+
applied per component — both separators are split for the same reason
|
|
189
|
+
:func:`names_tree_root` splits both.
|
|
190
|
+
|
|
191
|
+
**Rule 1 — reserved device basenames.** ``_RESERVED_BASENAMES`` holds ``CON``,
|
|
192
|
+
``PRN``, ``AUX``, ``NUL``, the console pair ``CONIN$``/``CONOUT$``, ``COM0``
|
|
193
|
+
through ``COM9``, ``LPT0`` through ``LPT9``, and the ISO-8859-1 superscript
|
|
194
|
+
``COM¹``/``COM²``/``COM³`` and ``LPT¹``/``LPT²``/``LPT³`` forms.
|
|
195
|
+
:func:`_is_reserved_basename` compares case-insensitively, with or without an
|
|
196
|
+
extension, and trims trailing spaces before comparing — ``nul``, ``NUL.txt``
|
|
197
|
+
and ``CON .txt`` all count, and the trim-first ordering is right because Win32
|
|
198
|
+
strips the trailing run *before* it tests for a device (``aux.. ..`` resolves
|
|
199
|
+
to AUX). It is a *segment* predicate: it splits on the first dot of the whole
|
|
200
|
+
string, so ``_is_reserved_basename("sub/NUL")`` is False. Applying it per
|
|
201
|
+
component is what puts ``"sub/NUL"`` in reach at all.
|
|
202
|
+
|
|
203
|
+
That set is deliberately a superset of Microsoft's published list, which names
|
|
204
|
+
only ``COM1``-``COM9`` and ``LPT1``-``LPT9`` and omits the console pair
|
|
205
|
+
entirely — Wine's ``RtlIsDosDeviceName_U`` matches ``CONIN$``/``CONOUT$`` and
|
|
206
|
+
rejects the ``0`` forms, so ``COM0``/``LPT0`` are refused here by neither
|
|
207
|
+
authority. Over-refusing six spellings nobody wants as a directory name is the
|
|
208
|
+
safe direction for a guard; do not read the set as a claim about Win32.
|
|
209
|
+
|
|
210
|
+
Windows 11 narrowed the rule the set encodes. Microsoft states the change (in
|
|
211
|
+
the .NET path-format documentation, not in the file-naming page, which still
|
|
212
|
+
asserts the old model): before Windows 11 a path *beginning* with a legacy
|
|
213
|
+
device name was always interpreted as that device, so ``CON.TXT`` meant
|
|
214
|
+
``\\\\.\\CON``; that no longer applies. Wine's conformance data encodes the
|
|
215
|
+
same narrowing case by case — ``C:\\con\\con`` carries a Windows 11 alternate
|
|
216
|
+
expectation of a literal path, and the extension forms are marked as failing
|
|
217
|
+
there — while bare ``NUL`` is left unmarked at every position. Bare ``NUL`` at
|
|
218
|
+
a leaf therefore stays a device on Windows 11, as though every existing
|
|
219
|
+
directory holds a virtual ``NUL``; ``sub/CON`` and ``NUL.txt`` do not. The
|
|
220
|
+
unnarrowed rule — hijack from any position, extension or not — holds on
|
|
221
|
+
Windows 10 and earlier.
|
|
222
|
+
|
|
223
|
+
We refuse the Windows 10 superset on every platform anyway, deliberately: a
|
|
224
|
+
config value must not mean one thing on one OS build and something else on the
|
|
225
|
+
next, and a guard that tracked the narrowing would turn a ``seed_files`` entry
|
|
226
|
+
into a build-number question. It is the same reasoning that already has the
|
|
227
|
+
family refusing ``C:\\secrets`` on POSIX.
|
|
228
|
+
|
|
229
|
+
**Rule 2 — the trailing period/space trim.** Win32 removes every trailing
|
|
230
|
+
period and space from a path component, so ``".claude/skills."`` creates and
|
|
231
|
+
addresses ``.claude/skills`` while the configured string still spells
|
|
232
|
+
``skills.``. The divergence reaches past the filesystem: git's gitignore parser
|
|
233
|
+
reads the authored spelling, so a shield pattern rendered from the config
|
|
234
|
+
matches ``skills.`` and misses the directory Win32 actually made.
|
|
235
|
+
|
|
236
|
+
Rule 2 reads the same trim :func:`names_tree_root` does, split by what the
|
|
237
|
+
whole *value* amounts to rather than by component. That predicate owns a value
|
|
238
|
+
made *entirely* of period/space components, where the trim leaves nothing at
|
|
239
|
+
any level and the value names the tree root. This one owns everything else the
|
|
240
|
+
trim touches: a component the trim shortens (``"skills. "`` names a sibling of
|
|
241
|
+
what was written) and equally a component the trim *empties* when it sits
|
|
242
|
+
beside a real one — ``"sub/..."`` is nobody's root and nobody's parent, so it
|
|
243
|
+
is an alias and belongs here (it addresses ``sub`` on Windows and a literal
|
|
244
|
+
``...`` directory on POSIX; the first review round caught it slipping all four
|
|
245
|
+
members). The ``not root_naming`` term draws that line, and the
|
|
246
|
+
``part not in (".", "..")`` carve-out beside it hands the two spellings that
|
|
247
|
+
mean the *same* path on every platform back to their owners — ``"."`` is a
|
|
248
|
+
no-op component everywhere, ``".."`` is :func:`has_parent_ref`'s climb. All
|
|
249
|
+
four members therefore refuse disjoint spelling classes — which is what lets
|
|
250
|
+
each be ablated on its own, and mirrors :func:`names_tree_root`'s own
|
|
251
|
+
``part != ".."`` carve-out one function up.
|
|
252
|
+
|
|
253
|
+
The git half of rule 2 is measured, on this repo's own suite. **The Win32
|
|
254
|
+
filesystem half is cited, not measured** — this is a Linux box and nothing here
|
|
255
|
+
calls a Win32 API. The sources are Microsoft's "Naming Files, Paths, and
|
|
256
|
+
Namespaces" for the reserved list and the ``NUL.txt`` equivalence; Microsoft's
|
|
257
|
+
".NET File path formats on Windows systems" for the trim rule and the Windows
|
|
258
|
+
11 statement; Wine's ntdll path conformance tests (``test_RtlGetFullPathName_U``
|
|
259
|
+
and ``test_RtlIsDosDeviceName_U``, against ``collapse_path`` and
|
|
260
|
+
``RtlIsDosDeviceName_U``) for the per-case narrowing and the ``NUL`` carve-out;
|
|
261
|
+
and Project Zero's "The Definitive Guide on Win32 to NT Path Conversion" (2016,
|
|
262
|
+
so pre-narrowing) for the mechanism. The ``NUL`` carve-out is stated by none of
|
|
263
|
+
Microsoft's pages."""
|
|
264
|
+
text = str(value)
|
|
265
|
+
# Same both-separator split as `names_tree_root`, and for the same reason: a
|
|
266
|
+
# value is judged by the components Win32 would see.
|
|
267
|
+
parts = [part for part in text.replace("\\", "/").split("/") if part]
|
|
268
|
+
# A value made ENTIRELY of period/space components names the tree root and is
|
|
269
|
+
# `names_tree_root`'s to refuse; scoping rule 2 by the WHOLE value rather than
|
|
270
|
+
# per component is what keeps the two disjoint while still catching an
|
|
271
|
+
# all-period/space component embedded beside a real one (`sub/...`), which is
|
|
272
|
+
# nobody's root and nobody's parent.
|
|
273
|
+
root_naming = names_tree_root(text)
|
|
274
|
+
return any(
|
|
275
|
+
_is_reserved_basename(part)
|
|
276
|
+
or (
|
|
277
|
+
part != part.rstrip(" .")
|
|
278
|
+
# `.` and `..` spell the same path on every platform — the no-op
|
|
279
|
+
# component, and the climb `has_parent_ref` owns — the same
|
|
280
|
+
# carve-out, for the same reason, as `names_tree_root`'s `..`.
|
|
281
|
+
and part not in (".", "..")
|
|
282
|
+
and not root_naming
|
|
283
|
+
)
|
|
284
|
+
for part in parts
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_wsl_unc_path(value: str | Path) -> bool:
|
|
289
|
+
"""True if ``value`` addresses a WSL distro's filesystem through the Windows UNC
|
|
290
|
+
bridge — ``\\\\wsl.localhost\\<distro>\\...`` or its legacy ``\\\\wsl$\\<distro>\\...``
|
|
291
|
+
spelling, matched case-insensitively and with either separator, since Windows
|
|
292
|
+
accepts ``//wsl$/...`` as readily as the backslash form.
|
|
293
|
+
|
|
294
|
+
Used to spot a native-Windows interpreter working a distro path — the #332
|
|
295
|
+
mis-pick, where WSL's appended Windows ``PATH`` lands a bash prompt on a ``win32``
|
|
296
|
+
build that takes the win32 defaults and never sees the distro's tmux.
|
|
297
|
+
|
|
298
|
+
The path is the signal because the obvious alternative does not survive: probed on
|
|
299
|
+
a live interop launch (Windows 11, WSL2, Ubuntu-24.04, 2026-08), ``WSL_DISTRO_NAME``
|
|
300
|
+
and ``WSL_INTEROP`` were absent from the child and ``PWD``, when present at all,
|
|
301
|
+
carried the *Windows*-side parent's value rather than the distro cwd — WSL hands a
|
|
302
|
+
Windows binary the *Windows* environment block. So an env marker is not merely
|
|
303
|
+
missing, it can be present and wrong. Nothing pins that observation, so re-probe
|
|
304
|
+
before adding an env-marker check rather than assuming one would work.
|
|
305
|
+
|
|
306
|
+
Platform-blind by design (reads no ``sys.platform``): the "is this interpreter the
|
|
307
|
+
wrong one" half stays at the call site.
|
|
308
|
+
|
|
309
|
+
Callers pass a resolved path (``cli._project``), and resolution is what decides the
|
|
310
|
+
coverage: measured on Windows 11 / CPython 3.13, ``Path.resolve()`` leaves both
|
|
311
|
+
bridge spellings untouched, folds ``//wsl.localhost/...`` into the backslash form,
|
|
312
|
+
and dereferences a mapped drive or ``subst`` alias back to the UNC spelling — so all
|
|
313
|
+
of those match. An extended-length ``\\\\?\\UNC\\...`` prefix the input already
|
|
314
|
+
carried (``ntpath.realpath`` only strips one it added itself) is folded down to the
|
|
315
|
+
plain UNC form here, so that spelling matches too. A spelling this still misses
|
|
316
|
+
degrades gracefully: the ``mux.selection`` line names the platform regardless.
|
|
317
|
+
|
|
318
|
+
One caller now passes an *un*resolved path: when the OS refuses to canonicalize,
|
|
319
|
+
:func:`resolve_or_lexical` degrades to ``absolute()``, which is the only way this
|
|
320
|
+
check runs at all on the host it is for (#552). All four bridge spellings still
|
|
321
|
+
match — they are already absolute — but the mapped-drive/``subst`` dereference is
|
|
322
|
+
gone, so a substituted drive letter over a *dead* provider goes unflagged. That is
|
|
323
|
+
the graceful-degradation case above, not a new one: the finding is a warning, and
|
|
324
|
+
``mux.selection`` still names the platform."""
|
|
325
|
+
text = str(value).replace("/", "\\").lower()
|
|
326
|
+
if text.startswith("\\\\?\\unc\\"):
|
|
327
|
+
text = "\\\\" + text[len("\\\\?\\unc\\") :]
|
|
328
|
+
return text.startswith(("\\\\wsl.localhost\\", "\\\\wsl$\\"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# One note per degraded spelling per process. A single invocation canonicalizes the
|
|
332
|
+
# project root at least twice — `main()` pre-dispatch, then the handler's own
|
|
333
|
+
# `_project` — and one condition must not print two lines.
|
|
334
|
+
_LEXICAL_FALLBACK_NOTED: set[str] = set()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def resolve_or_lexical(path: str | Path) -> Path:
|
|
338
|
+
"""``Path(path).resolve()``, degrading to a *lexical* absolutization — and one
|
|
339
|
+
note on stderr — when the OS refuses to canonicalize the path (#552).
|
|
340
|
+
|
|
341
|
+
The condition this exists for: on a Windows host whose WSL UNC provider is
|
|
342
|
+
registered but not serving, resolving ``\\\\wsl$\\<distro>\\...`` raises
|
|
343
|
+
``ERROR_NETNAME_DELETED`` (WinError 64). CPython's non-strict
|
|
344
|
+
``ntpath._getfinalpathname_nonstrict`` re-raises any winerror outside its
|
|
345
|
+
allow-list, and 64 is not on it, so ``resolve()`` fails outright rather than
|
|
346
|
+
falling back to its own lexical walk. Reached from ``cli._project`` before
|
|
347
|
+
dispatch, that killed *every* subcommand at ``main()``'s backstop — including
|
|
348
|
+
``diagnose`` and ``validate``, whose ``host.win32-on-wsl-path`` finding names
|
|
349
|
+
that exact host. The warning was unreachable on the only hosts it is for.
|
|
350
|
+
|
|
351
|
+
``RuntimeError`` is caught alongside ``OSError`` because ``resolve()`` raises it,
|
|
352
|
+
not an ``OSError``, for a symlink loop on the 3.11/3.12 floor — the asymmetry
|
|
353
|
+
``install._shield_undo_extension`` documents; the pair is this repo's house guard,
|
|
354
|
+
applied at 17-odd sites already.
|
|
355
|
+
|
|
356
|
+
**Degrade, not fail** — deliberately, and bounded. The fallback is exactly
|
|
357
|
+
``Path(path).absolute()``: absolute, nothing else. It is enough for the
|
|
358
|
+
observation surface, because :func:`is_wsl_unc_path` is purely lexical and every
|
|
359
|
+
bridge spelling is already absolute, so ``absolute()`` hands it back untouched
|
|
360
|
+
(pathlib folds ``//wsl.localhost/...`` to the backslash form on the way). It is
|
|
361
|
+
*not* canonical, so this helper stays at the observation surface —
|
|
362
|
+
``cli._project``, which runs pre-dispatch where there is no handler to catch
|
|
363
|
+
anything, and ``froidconfig.worktree_isolation_conflict``'s comparison, which must
|
|
364
|
+
not kill ``validate`` ahead of the platform preflight. ``froidconfig.load_paths``
|
|
365
|
+
is the boundary and refuses instead — a typed ``FroidConfigError`` for the project
|
|
366
|
+
root *and* every configured path: a spelling the OS cannot canonicalize has an
|
|
367
|
+
unknowable location (it can sit lexically inside the project while an in-tree
|
|
368
|
+
junction carries it to a dead share outside), and classifying it by its spelling
|
|
369
|
+
is a guess that can redirect a worktree-isolated run's writes. The write paths
|
|
370
|
+
that need a canonical answer keep their bare ``resolve()`` and still raise:
|
|
371
|
+
``runs.project_tag`` digests ``str(project.resolve())`` into a session-ownership
|
|
372
|
+
tag, and two spellings of one project would strand live sessions. So a ``run`` on
|
|
373
|
+
such a host fails loud at config load, while ``validate``/``diagnose`` still
|
|
374
|
+
reach the finding that explains it — observation degrades, repair writes raise.
|
|
375
|
+
|
|
376
|
+
**Rejected: ``normpath(absolute(path))``.** Collapsing ``..`` lexically is not a
|
|
377
|
+
respelling, it names a *different directory* whenever the ``..`` crosses a
|
|
378
|
+
symlink — measured, not reasoned: with ``/home/u/link -> /var/x``,
|
|
379
|
+
``/home/u/link/../proj`` opens ``/var/proj``, and normpath yields
|
|
380
|
+
``/home/u/proj``. The raw value reaching here is persisted as ``state.project``
|
|
381
|
+
and reused as a git repo root and a session cwd (``runsetup.build_run_state``,
|
|
382
|
+
``runs``, ``resolve``), so that would be a silent wrong-directory write — a
|
|
383
|
+
failure class ``resolve()`` never had, introduced by the guard meant to soften
|
|
384
|
+
it. Plain ``absolute()`` keeps the ``..`` and lets the OS dereference it
|
|
385
|
+
correctly at every use, which is the whole reason to prefer it over a
|
|
386
|
+
prettier-looking string.
|
|
387
|
+
|
|
388
|
+
A relative ``path`` with an unreadable cwd still raises out of ``absolute()``:
|
|
389
|
+
there is no lexical answer to degrade to, and the backstop is the honest reply."""
|
|
390
|
+
try:
|
|
391
|
+
return Path(path).resolve()
|
|
392
|
+
except (OSError, RuntimeError) as e:
|
|
393
|
+
lexical = Path(path).absolute()
|
|
394
|
+
if str(lexical) not in _LEXICAL_FALLBACK_NOTED:
|
|
395
|
+
_LEXICAL_FALLBACK_NOTED.add(str(lexical))
|
|
396
|
+
# stderr, never stdout: `<cmd> --json` is a one-object-on-stdout contract.
|
|
397
|
+
print(
|
|
398
|
+
f"note: cannot canonicalize {path}: {e} — continuing with the lexical "
|
|
399
|
+
f"path {lexical} (symlinks are not dereferenced). "
|
|
400
|
+
"Run `froid-loop validate` for what this host is doing.",
|
|
401
|
+
file=sys.stderr,
|
|
402
|
+
)
|
|
403
|
+
return lexical
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _retry_on_sharing_violation(op: Callable[[], None]) -> None:
|
|
407
|
+
"""Run ``op``, retrying the transient Windows sharing violation a concurrent
|
|
408
|
+
handle on the file triggers (WinError 5/32). Gated to win32 so a real POSIX
|
|
409
|
+
EACCES/EPERM surfaces immediately instead of after a pointless backoff.
|
|
410
|
+
Worst-case total wait is ~5 s of jittered exponential backoff before the final
|
|
411
|
+
failure propagates."""
|
|
412
|
+
for attempt in range(_REPLACE_ATTEMPTS):
|
|
413
|
+
try:
|
|
414
|
+
op()
|
|
415
|
+
return
|
|
416
|
+
except OSError as exc:
|
|
417
|
+
last = attempt == _REPLACE_ATTEMPTS - 1
|
|
418
|
+
# a retryable open-handle denial, not a genuine permission fault
|
|
419
|
+
winerror = getattr(exc, "winerror", None)
|
|
420
|
+
retryable = isinstance(exc, PermissionError) or winerror in (5, 32)
|
|
421
|
+
# portability: only Windows denies a rename/delete over an open handle;
|
|
422
|
+
# elsewhere a permission error is real and must surface at once.
|
|
423
|
+
if sys.platform != "win32" or last or not retryable:
|
|
424
|
+
raise
|
|
425
|
+
delay = min(_REPLACE_CAP_S, _REPLACE_BASE_S * 2**attempt)
|
|
426
|
+
time.sleep(delay + random.uniform(0, _REPLACE_BASE_S)) # nosec B311 - retry jitter
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def atomic_replace(tmp: Path, target: Path) -> None:
|
|
430
|
+
"""``os.replace(tmp, target)``, retried on the transient Windows sharing
|
|
431
|
+
violation a concurrent reader of ``target`` triggers."""
|
|
432
|
+
_retry_on_sharing_violation(lambda: os.replace(tmp, target))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _copy_xattrs(src: Path, dst: Path) -> None:
|
|
436
|
+
"""Best-effort extended-attribute copy (Linux). Absent everywhere else, and
|
|
437
|
+
unsupported by many filesystems even there, so every failure is ignored: an
|
|
438
|
+
xattr we could not carry over is not worth failing a ledger write for."""
|
|
439
|
+
listxattr = getattr(os, "listxattr", None)
|
|
440
|
+
if listxattr is None: # portability: xattr syscalls are Linux-only
|
|
441
|
+
return
|
|
442
|
+
try:
|
|
443
|
+
names = listxattr(src)
|
|
444
|
+
except OSError:
|
|
445
|
+
return
|
|
446
|
+
for name in names:
|
|
447
|
+
try:
|
|
448
|
+
os.setxattr(dst, name, os.getxattr(src, name))
|
|
449
|
+
except OSError:
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# Matched as a code-point range rather than by an encode round trip, the same way
|
|
454
|
+
# ``engine._TITLE_CONTROL_RE`` reaches these: a Python ``str`` holds code points,
|
|
455
|
+
# so an astral character like U+1D11E is one code point *outside* this range and
|
|
456
|
+
# is never touched. Only genuinely lone surrogates match.
|
|
457
|
+
_SURROGATES_RE = re.compile(r"[\ud800-\udfff]")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def neutralize_surrogates(text: str) -> str:
|
|
461
|
+
"""Replace every lone surrogate in ``text`` with U+FFFD (``�``).
|
|
462
|
+
|
|
463
|
+
A surrogate is a legal ``str`` code point with **no UTF-8 encoding at all**,
|
|
464
|
+
so any strict encode — :func:`atomic_write_text`'s included — raises
|
|
465
|
+
``UnicodeEncodeError`` on one. That is a ``ValueError`` subclass, which is
|
|
466
|
+
how a single unpaired code point reaches a caller as a crash rather than as
|
|
467
|
+
mangled text. They arrive from anywhere a decoder is allowed to mint them:
|
|
468
|
+
``json.loads`` reviving a ``\\ud800`` escape, a double-quoted YAML scalar, a
|
|
469
|
+
``surrogateescape`` decode of undecodable filesystem bytes.
|
|
470
|
+
|
|
471
|
+
Replace, not strip and not refuse. U+FFFD keeps the value **visible** — the
|
|
472
|
+
field still says *something unencodable was here* — where dropping the code
|
|
473
|
+
point would let it vanish silently and refusing would only move the stoppage
|
|
474
|
+
upstream. It is also why this is a substitution rather than the shorter
|
|
475
|
+
``text.encode("utf-8", "replace").decode("utf-8")``: that spelling yields
|
|
476
|
+
``"?"``, indistinguishable from a question mark the author actually typed.
|
|
477
|
+
|
|
478
|
+
Text with no surrogate is returned untouched — the identical object, so a
|
|
479
|
+
clean write stays byte-identical."""
|
|
480
|
+
if not _SURROGATES_RE.search(text):
|
|
481
|
+
return text
|
|
482
|
+
return _SURROGATES_RE.sub("�", text)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def atomic_write_text(
|
|
486
|
+
path: Path,
|
|
487
|
+
text: str,
|
|
488
|
+
*,
|
|
489
|
+
follow_symlinks: bool = True,
|
|
490
|
+
require_writable_target: bool = False,
|
|
491
|
+
) -> None:
|
|
492
|
+
"""Replace ``path``'s contents with ``text`` atomically, preserving what the
|
|
493
|
+
replacement would otherwise silently discard.
|
|
494
|
+
|
|
495
|
+
``os.replace`` swaps a *new inode* into place, so a naive tmp-write-and-replace
|
|
496
|
+
quietly resets everything carried by the old file rather than by its name. This
|
|
497
|
+
restores the parts that matter:
|
|
498
|
+
|
|
499
|
+
* **Symlinks are followed.** ``path.resolve()`` first, so a ledger symlinked
|
|
500
|
+
into the repo keeps being a symlink and the real file is what gets rewritten
|
|
501
|
+
— a replace against the link itself would turn it into a regular file and
|
|
502
|
+
orphan the target. Pass ``follow_symlinks=False`` to invert that: the name
|
|
503
|
+
is replaced, whatever it points at. Right for a machine-minted file living
|
|
504
|
+
somewhere a less-trusted writer can reach, where honouring a planted link
|
|
505
|
+
would aim this write at a path of that writer's choosing; wrong for the
|
|
506
|
+
operator-curated ledgers this helper was built for, hence the default.
|
|
507
|
+
* **Permission bits survive.** A ``0600`` file stays ``0600`` instead of
|
|
508
|
+
becoming ``0644 & ~umask``, which on a shared artifact dir is the difference
|
|
509
|
+
between "the group can still write this" and a silent lockout (or a
|
|
510
|
+
disclosure).
|
|
511
|
+
* **Extended attributes survive** where the platform has them (best effort).
|
|
512
|
+
|
|
513
|
+
Ownership is NOT preserved — an unprivileged process cannot chown — so a file
|
|
514
|
+
written by another user changes hands. Callers writing genuinely shared,
|
|
515
|
+
multi-user state need more than this helper. A target that does not exist yet
|
|
516
|
+
is created with ``mkstemp``'s private ``0600``, not the umask default: there is
|
|
517
|
+
no prior mode to carry over, and the restrictive choice is the safe one.
|
|
518
|
+
|
|
519
|
+
The temp file is uniquely named in the target's own directory: same filesystem
|
|
520
|
+
(``os.replace`` cannot cross one), and no fixed ``.tmp`` sibling for a
|
|
521
|
+
concurrent writer of the same file to collide with. A failure anywhere leaves
|
|
522
|
+
the original untouched and removes the temp.
|
|
523
|
+
|
|
524
|
+
The contents are **fsynced before the replace publishes them**. Closing a file
|
|
525
|
+
only hands the data to the page cache, so a machine that loses power just
|
|
526
|
+
after the rename can come back with the new name pointing at blocks that were
|
|
527
|
+
never written — a zero-length or torn ledger, which parses as *no entries* and
|
|
528
|
+
so reads as the whole file's worth of hand-written work having vanished.
|
|
529
|
+
Ordering the flush before the rename means a crash yields either the old file
|
|
530
|
+
or the complete new one. The directory itself is deliberately not synced: that
|
|
531
|
+
would make the *rename* durable, and losing the rename just leaves the old
|
|
532
|
+
contents in place — stale, never corrupt.
|
|
533
|
+
|
|
534
|
+
``require_writable_target=True`` refuses the write when the target already
|
|
535
|
+
exists and the kernel will not open it for writing — the property a
|
|
536
|
+
temp-and-replace write otherwise loses, because ``os.replace`` needs write
|
|
537
|
+
permission on the *directory* and never opens the entry it replaces (#597).
|
|
538
|
+
Off by default: that is the behavior every caller has today, and turning it on
|
|
539
|
+
for all of them would refuse writes that currently succeed. See
|
|
540
|
+
:func:`_refuse_unwritable_target` for what the probe does and does not promise.
|
|
541
|
+
|
|
542
|
+
The bytes sibling is :func:`atomic_write_bytes`; the two share every property
|
|
543
|
+
above and differ only in the ``os.fdopen`` mode. Text mode's *newline*
|
|
544
|
+
default (translating) is deliberate here — it matches the ``Path.write_text``
|
|
545
|
+
this replaced, so a ledger's line endings do not change under Windows."""
|
|
546
|
+
_atomic_write(
|
|
547
|
+
path,
|
|
548
|
+
text,
|
|
549
|
+
mode="w",
|
|
550
|
+
encoding="utf-8",
|
|
551
|
+
follow_symlinks=follow_symlinks,
|
|
552
|
+
require_writable_target=require_writable_target,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def atomic_write_bytes(
|
|
557
|
+
path: Path,
|
|
558
|
+
data: bytes,
|
|
559
|
+
*,
|
|
560
|
+
follow_symlinks: bool = True,
|
|
561
|
+
require_writable_target: bool = False,
|
|
562
|
+
) -> None:
|
|
563
|
+
"""Replace ``path``'s contents with ``data`` atomically — the byte-exact
|
|
564
|
+
sibling of :func:`atomic_write_text`, whose docstring carries the shared
|
|
565
|
+
contract (mode and xattrs preserved when the target already exists and
|
|
566
|
+
symlinks are followed, a fresh target left at ``mkstemp``'s private ``0600``,
|
|
567
|
+
fsync before the replace, temp removed on any failure).
|
|
568
|
+
|
|
569
|
+
``follow_symlinks`` behaves exactly as it does there. The default resolves
|
|
570
|
+
``path`` first, so a file symlinked into the repo keeps being a symlink and
|
|
571
|
+
the real file is what gets rewritten — a replace against the link itself would
|
|
572
|
+
turn it into a regular file and orphan the target. ``follow_symlinks=False``
|
|
573
|
+
inverts that: the *name* is replaced, whatever it points at, and mode and
|
|
574
|
+
xattrs are then not inherited at all. Right for a machine-minted file living
|
|
575
|
+
somewhere a less-trusted writer can reach, where honouring a planted link
|
|
576
|
+
would aim this write at a path of that writer's choosing; wrong for the
|
|
577
|
+
operator-curated files the default was built for — including the private git
|
|
578
|
+
exclude ``install._worktree_local_exclude`` writes, which pre-creates the
|
|
579
|
+
target precisely so this helper has a umask mode to carry over.
|
|
580
|
+
|
|
581
|
+
The one difference from the text sibling is the whole point: ``data`` lands
|
|
582
|
+
byte-for-byte. No encode and no newline translation, so a payload carrying LF
|
|
583
|
+
keeps LF on Windows and bytes that are not valid text in any codec survive the
|
|
584
|
+
round trip. Callers handling filesystem-derived content want this variant — a
|
|
585
|
+
POSIX filename is arbitrary bytes, and an operator's git exclude file may be
|
|
586
|
+
in any legacy encoding at all — as do callers who read bytes to preserve a
|
|
587
|
+
file's existing line endings (``policy.write_mux_backend``).
|
|
588
|
+
|
|
589
|
+
``require_writable_target`` behaves exactly as it does in the text sibling."""
|
|
590
|
+
_atomic_write(
|
|
591
|
+
path,
|
|
592
|
+
data,
|
|
593
|
+
mode="wb",
|
|
594
|
+
encoding=None,
|
|
595
|
+
follow_symlinks=follow_symlinks,
|
|
596
|
+
require_writable_target=require_writable_target,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _is_name_too_long(exc: OSError) -> bool:
|
|
601
|
+
"""True if ``exc`` is the filesystem refusing a name for its LENGTH, under
|
|
602
|
+
either platform's spelling of that condition.
|
|
603
|
+
|
|
604
|
+
Two spellings because win32 does not use the POSIX one: CPython's
|
|
605
|
+
``PC/errmap.h`` maps ``ERROR_FILENAME_EXCED_RANGE`` to ``ENOENT``, so there
|
|
606
|
+
the errno is indistinguishable from an absent directory and only
|
|
607
|
+
``.winerror`` tells them apart (see ``_WINERROR_FILENAME_EXCED_RANGE``)."""
|
|
608
|
+
if exc.errno == errno.ENAMETOOLONG:
|
|
609
|
+
return True
|
|
610
|
+
return getattr(exc, "winerror", None) == _WINERROR_FILENAME_EXCED_RANGE
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _stage_shortening(name: str, attempt: Callable[[str], tuple[int, str]]) -> tuple[int, str]:
|
|
614
|
+
"""Walk ``attempt`` down the staging-prefix ladder :func:`_mkstemp_beside`
|
|
615
|
+
documents: the target's readable name, then a fixed-width digest of it where
|
|
616
|
+
that strictly shortens, then no prefix at all. A rung that raises the
|
|
617
|
+
platform's "name too long" (:func:`_is_name_too_long`) falls to the next;
|
|
618
|
+
anything else propagates, and only the bare last rung's failure escapes.
|
|
619
|
+
|
|
620
|
+
Shared by both staging families — ``mkstemp`` beside a path and the ``O_EXCL``
|
|
621
|
+
create relative to a descriptor — because the guarantee is one guarantee: a
|
|
622
|
+
basename the target itself is legal at must stage, whichever writer the
|
|
623
|
+
caller reached (#595). The confined adoption briefly split them, and a spec
|
|
624
|
+
name near ``NAME_MAX`` wrote fine through the plain helper while the anchored
|
|
625
|
+
one died appending its suffix to the full name."""
|
|
626
|
+
digest = hashlib.blake2b(os.fsencode(name), digest_size=8).hexdigest()
|
|
627
|
+
rungs = [name + "."]
|
|
628
|
+
if len(digest) < len(name):
|
|
629
|
+
rungs.append(digest + ".")
|
|
630
|
+
rungs.append("")
|
|
631
|
+
for prefix in rungs[:-1]:
|
|
632
|
+
try:
|
|
633
|
+
return attempt(prefix)
|
|
634
|
+
except OSError as e:
|
|
635
|
+
if not _is_name_too_long(e):
|
|
636
|
+
raise
|
|
637
|
+
return attempt(rungs[-1])
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _mkstemp_beside(target: Path) -> tuple[int, str]:
|
|
641
|
+
"""``mkstemp`` in ``target``'s own directory, prefixed with its name so the
|
|
642
|
+
temp is recognisably that target's staging file — and so it ends in ``.tmp``
|
|
643
|
+
rather than the target's extension, which `devcontract._atomic_write_spec`
|
|
644
|
+
depends on to keep its temps out of the ``*.md`` artifact scans.
|
|
645
|
+
|
|
646
|
+
``mkstemp`` inserts 8 random characters between prefix and suffix, so the temp
|
|
647
|
+
name runs ``len(target.name) + 13``. A basename within 13 bytes of the
|
|
648
|
+
filesystem's ``NAME_MAX`` therefore makes the TEMP name illegal at a path the
|
|
649
|
+
target itself is perfectly legal at. Measured on ext4 (``NAME_MAX`` 255): a
|
|
650
|
+
242-byte basename stages fine, 243 raises ``ENAMETOOLONG`` while the direct
|
|
651
|
+
write it replaced succeeded through 255 (#595).
|
|
652
|
+
|
|
653
|
+
The fallback replaces the readable prefix with a digest of it rather than
|
|
654
|
+
truncating it. Truncation is the obvious fix and it is wrong: ``NAME_MAX``
|
|
655
|
+
counts BYTES while Python slices CHARACTERS, so a bounded character slice
|
|
656
|
+
neither guarantees a legal name nor avoids splitting a UTF-8 sequence — and
|
|
657
|
+
the limit is not portably knowable anyway (``os.pathconf`` is POSIX-only,
|
|
658
|
+
NTFS counts UTF-16 code units, and win32 is usually bound by ``MAX_PATH`` on
|
|
659
|
+
the whole path instead). Letting the OS answer needs none of that arithmetic:
|
|
660
|
+
the common path keeps the readable name, and only a basename that cannot fit
|
|
661
|
+
degrades to a fixed-width digest.
|
|
662
|
+
|
|
663
|
+
``os.fsencode``, not ``str.encode``: a POSIX filename is arbitrary bytes and
|
|
664
|
+
may carry surrogates that a strict UTF-8 encode would raise on.
|
|
665
|
+
|
|
666
|
+
The retry keys on TWO spellings of one condition, because win32 does not use
|
|
667
|
+
the POSIX one: ``ERROR_FILENAME_EXCED_RANGE`` arrives as ``ENOENT`` and is
|
|
668
|
+
distinguishable only by ``.winerror`` (see
|
|
669
|
+
``_WINERROR_FILENAME_EXCED_RANGE``). Keying on ``ENAMETOOLONG`` alone left
|
|
670
|
+
this whole fallback dead on Windows — where, per the ``MAX_PATH`` note above,
|
|
671
|
+
it is if anything easier to reach than on ext4.
|
|
672
|
+
|
|
673
|
+
The rungs STRICTLY SHORTEN, and the last one carries no prefix at all. A
|
|
674
|
+
digest is 16 characters, so on its own it is not a fallback — against a
|
|
675
|
+
basename of 16 or fewer it stages a name no shorter than the one that just
|
|
676
|
+
failed, and retrying at the same width cannot succeed. That is unreachable
|
|
677
|
+
where the binding limit is per-component (a POSIX ``NAME_MAX`` rung is only
|
|
678
|
+
reached past 242 characters, far above the digest) and reachable where it is
|
|
679
|
+
the whole path, which is the win32 case: a short basename in a directory near
|
|
680
|
+
``MAX_PATH`` overflows on the staging suffix alone. So the digest rung is used
|
|
681
|
+
only while it actually shortens, and a bare ``mkstemp`` — the shortest name
|
|
682
|
+
this function can produce — always ends the ladder.
|
|
683
|
+
|
|
684
|
+
Only a failure at that last rung propagates. No choice of *prefix* can fix
|
|
685
|
+
that one — there is no prefix left — but that is a narrower statement than "the
|
|
686
|
+
directory is too long", and the difference is #596: ``mkstemp`` will not
|
|
687
|
+
generate a name below 12 characters (8 random, plus the ``.tmp`` this helper
|
|
688
|
+
needs to stay out of ``devcontract``'s ``*.md`` scans). What does not fit at
|
|
689
|
+
the last rung is therefore the directory PLUS those 12, which a target with a
|
|
690
|
+
shorter basename can still clear on a direct write. Shrinking below the floor
|
|
691
|
+
means abandoning ``mkstemp``, and with it the entropy that keeps the staged
|
|
692
|
+
name unpredictable where a less-trusted writer can reach it — see #591 for the
|
|
693
|
+
cost of a guessable temp name."""
|
|
694
|
+
directory = str(target.parent)
|
|
695
|
+
return _stage_shortening(
|
|
696
|
+
target.name,
|
|
697
|
+
lambda prefix: tempfile.mkstemp(dir=directory, prefix=prefix, suffix=".tmp"),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _refuse_unwritable_target(target: Path, *, follow_symlinks: bool) -> None:
|
|
702
|
+
"""Re-raise the kernel's own ``PermissionError`` when ``target`` exists and
|
|
703
|
+
will not open for writing — the opt-in half of ``require_writable_target``.
|
|
704
|
+
|
|
705
|
+
A temp-and-replace write never opens the file it replaces, and ``os.replace``
|
|
706
|
+
needs write permission on the *directory*, not on the entry being replaced. So
|
|
707
|
+
a file an operator marked ``0444`` is overwritten anyway — and where mode is
|
|
708
|
+
inherited (``follow_symlinks=True``) it comes back reading ``0444``, with
|
|
709
|
+
nothing in the permission bits recording that it changed. Every writer here
|
|
710
|
+
that once spelled ``Path.write_text`` refused that write as a side effect of
|
|
711
|
+
opening the file; going atomic dropped the refusal silently (#597). This asks
|
|
712
|
+
for it back, per caller, over the files an operator actually curates.
|
|
713
|
+
|
|
714
|
+
An actual ``os.open``, not ``os.access``: access(2) answers for the REAL uid
|
|
715
|
+
and only approximates ACLs, while the open is the kernel's answer under the
|
|
716
|
+
same credentials the write will use, and reproduces the exact error the direct
|
|
717
|
+
write used to raise. On win32 a READONLY file denies ``O_WRONLY`` with
|
|
718
|
+
``ERROR_ACCESS_DENIED``, which arrives as ``PermissionError``, so this arm is
|
|
719
|
+
real on both platforms rather than POSIX-only.
|
|
720
|
+
|
|
721
|
+
Three cases are deliberately NOT refusals, and all three simply return:
|
|
722
|
+
|
|
723
|
+
* **A missing target.** There is nothing to refuse — the write creates it, and
|
|
724
|
+
creation is what the flagged callers do on first run.
|
|
725
|
+
* **``ELOOP``**, i.e. a symlink at the name on the no-follow path. That write
|
|
726
|
+
replaces the NAME whatever it points at, on purpose, and a plantable link's
|
|
727
|
+
target mode is the planter's choice as much as anyone's (see
|
|
728
|
+
:func:`_atomic_write`). Win32 has no ``O_NOFOLLOW`` to raise ``ELOOP`` with,
|
|
729
|
+
so the same case is caught there by the :func:`is_link_like` pre-check below.
|
|
730
|
+
* **Any other ``OSError``** — a directory at the name, a full disk, a
|
|
731
|
+
disconnected share. Refusing here would replace the write's own, more
|
|
732
|
+
accurate error with a permission story that is not what happened.
|
|
733
|
+
|
|
734
|
+
``O_NONBLOCK`` is what keeps the third case reachable for a FIFO. Opening a
|
|
735
|
+
reader-less FIFO ``O_WRONLY`` does not fail — it WAITS for a reader that a
|
|
736
|
+
planted FIFO will never have, wedging the probe (and the loop driving it)
|
|
737
|
+
forever, and ``O_NOFOLLOW`` is no help because a FIFO is not a symlink.
|
|
738
|
+
Non-blocking turns that wait into ``ENXIO``, which the third case returns on,
|
|
739
|
+
and the write then replaces the FIFO's name like any other. For the regular
|
|
740
|
+
files this probe exists for the flag changes nothing: POSIX gives it no
|
|
741
|
+
effect on a regular-file open or on the permission check, and win32 — which
|
|
742
|
+
has no ``O_NONBLOCK`` — has no path-visible FIFOs to block on either.
|
|
743
|
+
|
|
744
|
+
This honours an operator's stated intent; it is NOT a boundary against a
|
|
745
|
+
same-uid writer. The answer is stale the moment it returns — a ``chmod``
|
|
746
|
+
between probe and replace still lands the write — and whoever can chmod the
|
|
747
|
+
file back can defeat it outright. :func:`_atomic_write` runs it BEFORE
|
|
748
|
+
``_mkstemp_beside``, so a refusal stages nothing."""
|
|
749
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0) # POSIX-only; win32 cannot ask
|
|
750
|
+
if not follow_symlinks and not no_follow and is_link_like(target):
|
|
751
|
+
return # win32: no O_NOFOLLOW, so the probe would read through the link
|
|
752
|
+
non_block = getattr(os, "O_NONBLOCK", 0) # POSIX-only; win32 has no FIFOs here
|
|
753
|
+
try:
|
|
754
|
+
fd = os.open(target, os.O_WRONLY | (0 if follow_symlinks else no_follow) | non_block)
|
|
755
|
+
except PermissionError:
|
|
756
|
+
raise # the refusal this flag exists for
|
|
757
|
+
except OSError:
|
|
758
|
+
return # missing, a link at the name, or something the write itself reports
|
|
759
|
+
os.close(fd)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _atomic_write(
|
|
763
|
+
path: Path,
|
|
764
|
+
payload: str | bytes,
|
|
765
|
+
*,
|
|
766
|
+
mode: str,
|
|
767
|
+
encoding: str | None,
|
|
768
|
+
follow_symlinks: bool = True,
|
|
769
|
+
require_writable_target: bool = False,
|
|
770
|
+
) -> None:
|
|
771
|
+
"""The shared body of the two public helpers above — see
|
|
772
|
+
:func:`atomic_write_text` for the contract every step here implements.
|
|
773
|
+
|
|
774
|
+
Written through ``os.fdopen`` rather than a raw ``os.write`` loop on purpose:
|
|
775
|
+
it routes to ``io.open``, the one seam a test can inject a short write at for
|
|
776
|
+
both variants at once (tests/test_install.py's #375 case).
|
|
777
|
+
|
|
778
|
+
``follow_symlinks=False`` skips the resolve, so the *name* is what gets
|
|
779
|
+
replaced. It needs no preflight ``is_symlink`` check to be safe, and that is
|
|
780
|
+
the reason to prefer it over one: ``os.replace`` does not dereference its
|
|
781
|
+
destination, so a link planted at any moment — including between a check and
|
|
782
|
+
this call — is overwritten rather than written through.
|
|
783
|
+
|
|
784
|
+
Mode and xattrs are then not inherited **at all**, and nothing is probed to
|
|
785
|
+
decide that. A name being replaced rather than updated should carry nothing
|
|
786
|
+
of whatever it used to point at, and in this mode there is no trustworthy
|
|
787
|
+
prior to carry over anyway: the caller asked for no-follow precisely because
|
|
788
|
+
a less-trusted writer can reach the name, so the mode found there is that
|
|
789
|
+
writer's choice as much as anyone's. Probing first and copying after would
|
|
790
|
+
also reopen by the back door the very window the paragraph above closes —
|
|
791
|
+
``shutil.copymode`` re-resolves the path it is handed, so a link planted
|
|
792
|
+
between the probe and the copy hands the new record the mode of a file of
|
|
793
|
+
the planter's choosing (the contents stay safe; ``os.replace`` still does not
|
|
794
|
+
dereference). Taking no probe leaves no window to race, and ``mkstemp``'s
|
|
795
|
+
private ``0600`` is the right mode for the machine-minted file this mode
|
|
796
|
+
exists for.
|
|
797
|
+
|
|
798
|
+
``require_writable_target=True`` inserts :func:`_refuse_unwritable_target`
|
|
799
|
+
between the resolve and the staging, in that order on purpose: a refusal then
|
|
800
|
+
stages nothing, so a caller that declines to overwrite a read-only file also
|
|
801
|
+
leaves no temp behind to explain."""
|
|
802
|
+
target = path.resolve() if follow_symlinks else path
|
|
803
|
+
if require_writable_target:
|
|
804
|
+
_refuse_unwritable_target(target, follow_symlinks=follow_symlinks)
|
|
805
|
+
fd, tmp_name = _mkstemp_beside(target)
|
|
806
|
+
tmp = Path(tmp_name)
|
|
807
|
+
try:
|
|
808
|
+
with os.fdopen(fd, mode, encoding=encoding) as fh:
|
|
809
|
+
fh.write(payload)
|
|
810
|
+
fh.flush() # userspace buffer -> kernel, so there is something to sync
|
|
811
|
+
os.fsync(fh.fileno())
|
|
812
|
+
if follow_symlinks and target.exists():
|
|
813
|
+
shutil.copymode(target, tmp)
|
|
814
|
+
_copy_xattrs(target, tmp)
|
|
815
|
+
atomic_replace(tmp, target)
|
|
816
|
+
except BaseException:
|
|
817
|
+
with suppress(OSError):
|
|
818
|
+
try:
|
|
819
|
+
tmp.unlink()
|
|
820
|
+
except PermissionError:
|
|
821
|
+
# win32 DeleteFile refuses a READONLY file, and the copymode
|
|
822
|
+
# above stamps the target's READONLY bit onto the temp — so a
|
|
823
|
+
# publish denied over a read-only destination would leak it.
|
|
824
|
+
# POSIX never takes this arm: unlink consults the parent
|
|
825
|
+
# directory's permission, never the entry's own mode.
|
|
826
|
+
os.chmod(tmp, stat.S_IWRITE)
|
|
827
|
+
tmp.unlink()
|
|
828
|
+
raise
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# Whether this platform has the `*at()` family the two helpers below need. The
|
|
832
|
+
# probe is `O_DIRECTORY` rather than `os.supports_dir_fd`: that set tracks only
|
|
833
|
+
# the literal `dir_fd` parameter, so `os.replace` — which spells it
|
|
834
|
+
# `src_dir_fd`/`dst_dir_fd` — is absent from it even on Linux, where renameat
|
|
835
|
+
# works. CPython gates the whole family on one configure pass, so the flag's
|
|
836
|
+
# presence answers for all of them: it is defined on Linux/macOS and absent on
|
|
837
|
+
# Windows, whose pyconfig has neither HAVE_RENAMEAT nor HAVE_OPENAT.
|
|
838
|
+
DIR_FD_ANCHORED_WRITES = hasattr(os, "O_DIRECTORY")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# Windows reparse tags that make a directory entry REDIRECT somewhere else,
|
|
842
|
+
# compared against os.lstat().st_reparse_tag (Windows, 3.8+). Deliberately not
|
|
843
|
+
# os.path.isjunction(), which is 3.12+ while this package's floor is 3.11.
|
|
844
|
+
# Deliberately not "any reparse tag" either: cloud placeholders (OneDrive) and
|
|
845
|
+
# dedup stubs are reparse points too, and refusing those would stall a
|
|
846
|
+
# legitimate run. Empty on POSIX.
|
|
847
|
+
_LINK_REPARSE_TAGS = tuple(
|
|
848
|
+
tag
|
|
849
|
+
for tag in (
|
|
850
|
+
getattr(stat, "IO_REPARSE_TAG_SYMLINK", None),
|
|
851
|
+
getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None),
|
|
852
|
+
)
|
|
853
|
+
if tag is not None
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def is_link_like(path: Path) -> bool:
|
|
858
|
+
"""True when ``path`` redirects elsewhere: a POSIX symlink, or a Windows
|
|
859
|
+
symlink OR DIRECTORY JUNCTION.
|
|
860
|
+
|
|
861
|
+
``Path.is_symlink()`` is False for a junction — junctions are a distinct
|
|
862
|
+
reparse kind, which is why ``os.path.isjunction()`` exists at all. On Windows
|
|
863
|
+
the junction is the arm that matters: ``mklink /J`` needs no elevation, while
|
|
864
|
+
a directory symlink needs SeCreateSymbolicLinkPrivilege or Developer Mode, so
|
|
865
|
+
the UNPRIVILEGED redirect is exactly the one an ``is_symlink()`` check misses.
|
|
866
|
+
|
|
867
|
+
This is the win32 half of :func:`open_dir_confined`, which anchors the POSIX
|
|
868
|
+
side at a descriptor instead. A path check is inherently check-then-write —
|
|
869
|
+
answered about a name, and stale the moment it returns — so it narrows the
|
|
870
|
+
window rather than closing it. That residual is the platform's, not this
|
|
871
|
+
function's: win32 has no ``*at()`` family to anchor against.
|
|
872
|
+
|
|
873
|
+
``events.py`` and the standalone hook relay keep their own copies of this
|
|
874
|
+
predicate on purpose: they run under the HOST's interpreter, not this
|
|
875
|
+
package's, so they cannot import it from here.
|
|
876
|
+
"""
|
|
877
|
+
if path.is_symlink():
|
|
878
|
+
return True
|
|
879
|
+
try:
|
|
880
|
+
return getattr(os.lstat(path), "st_reparse_tag", 0) in _LINK_REPARSE_TAGS
|
|
881
|
+
except OSError:
|
|
882
|
+
return False
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class UnconfinedWriteError(OSError):
|
|
886
|
+
"""A confined write refused: the path is not under its root, or a component
|
|
887
|
+
below that root is a link, is missing, or cannot be probed.
|
|
888
|
+
|
|
889
|
+
An ``OSError`` subclass on purpose. Every site that adopts a confined writer
|
|
890
|
+
already degrades on ``OSError`` from the same call — ``runs.stop_run``
|
|
891
|
+
swallows a failed stop-request write, the engine journals a failed park
|
|
892
|
+
rollback, the settings screen reports a failed save — so a refusal arrives in
|
|
893
|
+
the handling those callers already have rather than escaping as a new
|
|
894
|
+
exception type nobody catches. A caller that wants to tell a refusal apart
|
|
895
|
+
from a disk error can still catch this name specifically.
|
|
896
|
+
|
|
897
|
+
The read-only refusal (``require_writable_target``) is deliberately NOT this
|
|
898
|
+
class: it re-raises the kernel's own ``PermissionError``, which is exactly
|
|
899
|
+
what a bare ``Path.write_text`` raised before these writes went atomic."""
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def path_is_confined(root: Path, target: Path) -> bool:
|
|
903
|
+
"""Whether ``target`` is reached from ``root`` without traversing a redirect
|
|
904
|
+
at any component below it.
|
|
905
|
+
|
|
906
|
+
The win32 half of :func:`open_dir_confined`, which anchors the POSIX side at
|
|
907
|
+
a descriptor instead. A check, not a race-free open: it is answered about a
|
|
908
|
+
NAME and is stale the moment it returns, so it removes the standing redirect
|
|
909
|
+
— plant a link, wait for a write — while a writer who re-plants inside the
|
|
910
|
+
window between check and write still wins. That residual is the platform's,
|
|
911
|
+
not this function's: win32 has no ``*at()`` family to anchor against.
|
|
912
|
+
|
|
913
|
+
Every component below ``root`` is checked and ``root`` itself is not: the
|
|
914
|
+
operator chooses where the project lives and may well keep it behind a link,
|
|
915
|
+
while everything under it is session-writable. That is the same split
|
|
916
|
+
:func:`open_dir_confined` makes by opening ``root`` without ``O_NOFOLLOW``.
|
|
917
|
+
|
|
918
|
+
``lstat``-based throughout, so the walk never resolves through the thing it
|
|
919
|
+
is testing for — and an ``lstat`` that RAISES answers False, because a
|
|
920
|
+
component that cannot be probed is one this cannot vouch for. ``Path``'s own
|
|
921
|
+
predicates swallow that error and answer "not a link", which walks PAST the
|
|
922
|
+
component instead: the opposite of what a confinement check owes its caller.
|
|
923
|
+
|
|
924
|
+
Both link kinds count. ``S_ISLNK`` is the whole answer on POSIX; on win32 a
|
|
925
|
+
DIRECTORY JUNCTION redirects identically, is invisible to ``is_symlink()``,
|
|
926
|
+
and is the cheaper plant (``mklink /J`` needs neither elevation nor Developer
|
|
927
|
+
Mode). Recognised through :data:`_LINK_REPARSE_TAGS` rather than
|
|
928
|
+
``os.path.isjunction``, which is 3.12+ while this package's floor is 3.11.
|
|
929
|
+
|
|
930
|
+
Narrower than ``tui/launch._run_dir_is_confined``, which refuses ANY reparse
|
|
931
|
+
point. Over-refusing is the cheap direction there — the cost is one unwritten
|
|
932
|
+
hint — but this backs writes an operator's own configuration depends on, so a
|
|
933
|
+
OneDrive placeholder or a dedup stub in the ancestry must not turn every
|
|
934
|
+
policy write into a failure.
|
|
935
|
+
|
|
936
|
+
Confinement is answered about the LEXICAL spelling handed in, exactly as
|
|
937
|
+
:func:`open_dir_confined` answers it; a caller building ``target`` out of
|
|
938
|
+
untrusted parts owes its own ``..`` check first (:func:`has_parent_ref`)."""
|
|
939
|
+
try:
|
|
940
|
+
if not target.is_relative_to(root):
|
|
941
|
+
return False
|
|
942
|
+
cursor = target
|
|
943
|
+
while cursor != root:
|
|
944
|
+
info = os.lstat(cursor)
|
|
945
|
+
if stat.S_ISLNK(info.st_mode):
|
|
946
|
+
return False
|
|
947
|
+
if getattr(info, "st_reparse_tag", 0) in _LINK_REPARSE_TAGS: # win32-only field
|
|
948
|
+
return False
|
|
949
|
+
cursor = cursor.parent
|
|
950
|
+
except OSError:
|
|
951
|
+
return False # a component we cannot probe is one we cannot vouch for
|
|
952
|
+
return True
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def walk_files_unlinked(top: Path) -> Iterator[Path]:
|
|
956
|
+
"""Every non-directory entry under ``top``, never crossing a redirect out of it.
|
|
957
|
+
|
|
958
|
+
**Non-directory, not regular file** — ``os.walk`` puts FIFOs, device nodes and
|
|
959
|
+
symlinks in ``files`` alongside ordinary ones, and this yields what it is
|
|
960
|
+
handed. A caller that only counts or ``lstat``s is fine; a caller that OPENS
|
|
961
|
+
what it yields owes its own regular-file check, because opening a planted
|
|
962
|
+
FIFO blocks forever. Swapping ``rglob`` for this helper silently drops the
|
|
963
|
+
``is_file()`` guard the old loop carried — that regression shipped once
|
|
964
|
+
(``diagnostics.summarize_files``, whose ``logs`` arm reads to count lines).
|
|
965
|
+
|
|
966
|
+
Two holes, closed together because a caller that measures or counts a tree
|
|
967
|
+
gets both wrong in the same way:
|
|
968
|
+
|
|
969
|
+
``os.walk`` already declines to recurse into a symlinked subdirectory — but
|
|
970
|
+
it decides that with ``os.path.islink``, which is False for a Windows
|
|
971
|
+
DIRECTORY JUNCTION. That is the unprivileged redirect (see
|
|
972
|
+
:func:`is_link_like`), so on win32 the pruning `os.walk` documents is exactly
|
|
973
|
+
the arm an attacker would use. And ``os.walk`` always follows the top path it
|
|
974
|
+
is handed, symlink or not, so refusing to descend into links says nothing
|
|
975
|
+
about the root.
|
|
976
|
+
|
|
977
|
+
Both matter to more than tidiness: a session is handed a writable run
|
|
978
|
+
directory (`FROID_LOOP_RUN_DIR`) and can plant a link at an entry that `clean`
|
|
979
|
+
sizes and `diagnose` counts, which would bill a reclaim estimate — or a
|
|
980
|
+
diagnostic dump — for an arbitrarily large tree outside the run that neither
|
|
981
|
+
command touches. Yields paths; the caller chooses ``stat`` or ``lstat``.
|
|
982
|
+
"""
|
|
983
|
+
if is_link_like(top):
|
|
984
|
+
return
|
|
985
|
+
for root, dirs, files in os.walk(top, onerror=lambda _e: None):
|
|
986
|
+
# in-place, which is how os.walk documents pruning under topdown=True
|
|
987
|
+
dirs[:] = [d for d in dirs if not is_link_like(Path(root) / d)]
|
|
988
|
+
for name in files:
|
|
989
|
+
yield Path(root) / name
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def open_dir_confined(root: Path, target: Path) -> int | None:
|
|
993
|
+
"""An open descriptor for ``target``, reached from ``root`` without
|
|
994
|
+
traversing a symlink at any component below it — or None when that cannot be
|
|
995
|
+
established. The caller owns the descriptor and must ``os.close`` it.
|
|
996
|
+
|
|
997
|
+
A *descriptor*, not a verdict, and that is the whole point. A boolean
|
|
998
|
+
"is this path confined?" is answered about a path, and the answer is stale
|
|
999
|
+
the instant it returns: whoever can write those directories can swap one for
|
|
1000
|
+
a symlink before the caller gets around to opening anything. The descriptor
|
|
1001
|
+
this hands back is bound to the directory that was actually walked, so a
|
|
1002
|
+
later swap of any name along the way renames a path this no longer consults.
|
|
1003
|
+
Pair it with :func:`atomic_write_text_at`, which never names a path again.
|
|
1004
|
+
|
|
1005
|
+
Each component is opened ``O_NOFOLLOW | O_DIRECTORY`` relative to the one
|
|
1006
|
+
above it, so a link anywhere below ``root`` fails the open rather than being
|
|
1007
|
+
followed. ``root`` itself is opened without ``O_NOFOLLOW``: the operator
|
|
1008
|
+
chooses where the project lives and may keep it behind a link, while
|
|
1009
|
+
everything under it is session-writable.
|
|
1010
|
+
|
|
1011
|
+
POSIX only — see :data:`DIR_FD_ANCHORED_WRITES`. Callers need a fallback for
|
|
1012
|
+
win32, which has no ``*at()`` family to anchor against."""
|
|
1013
|
+
if not DIR_FD_ANCHORED_WRITES:
|
|
1014
|
+
return None
|
|
1015
|
+
try:
|
|
1016
|
+
relative = target.relative_to(root)
|
|
1017
|
+
except ValueError:
|
|
1018
|
+
return None # not under root at all
|
|
1019
|
+
try:
|
|
1020
|
+
fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
|
1021
|
+
except OSError:
|
|
1022
|
+
return None
|
|
1023
|
+
for part in relative.parts:
|
|
1024
|
+
try:
|
|
1025
|
+
nested = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
1026
|
+
except OSError:
|
|
1027
|
+
os.close(fd)
|
|
1028
|
+
return None # a link, a missing component, or one we cannot probe
|
|
1029
|
+
os.close(fd)
|
|
1030
|
+
fd = nested
|
|
1031
|
+
return fd
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
# Draws before giving up on a unique temp name. O_EXCL makes a collision
|
|
1035
|
+
# harmless, so this only bounds a pathological loop.
|
|
1036
|
+
_TMP_NAME_ATTEMPTS = 100
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def atomic_write_text_at(dir_fd: int, name: str, text: str) -> None:
|
|
1040
|
+
""":func:`atomic_write_text`, anchored at an open directory descriptor.
|
|
1041
|
+
|
|
1042
|
+
Every syscall here is relative to ``dir_fd``, so nothing resolves a path a
|
|
1043
|
+
concurrent writer could have redirected — the directory is the one
|
|
1044
|
+
:func:`open_dir_confined` walked to, whatever its name points at now. That
|
|
1045
|
+
closes the window a preflight path check leaves open, rather than narrowing
|
|
1046
|
+
it. ``name`` must be a single component.
|
|
1047
|
+
|
|
1048
|
+
Shares the shape of the path-based helper: unique temp in the same
|
|
1049
|
+
directory, contents fsynced *before* the replace publishes them, temp
|
|
1050
|
+
removed on any failure. It deliberately does NOT inherit mode or xattrs —
|
|
1051
|
+
this exists for machine-minted files under a session-writable root, where
|
|
1052
|
+
the prior file's mode is as untrusted as the rest of it, so the new record
|
|
1053
|
+
keeps the private ``0600`` it is created with. Text is written UTF-8 with
|
|
1054
|
+
no newline translation; the callers are records, not operator-edited files.
|
|
1055
|
+
|
|
1056
|
+
No win32 sharing-violation retry, unlike :func:`atomic_replace`: there is no
|
|
1057
|
+
win32 here at all — the ``*at()`` family this is built on does not exist
|
|
1058
|
+
there, so a caller reaching this is on POSIX by construction."""
|
|
1059
|
+
_atomic_write_at(dir_fd, name, text, mode="w", encoding="utf-8")
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def atomic_write_bytes_at(dir_fd: int, name: str, data: bytes) -> None:
|
|
1063
|
+
""":func:`atomic_write_text_at`'s byte-exact sibling, whose docstring carries
|
|
1064
|
+
the shared contract (a unique unguessable temp created ``O_EXCL`` at ``0600``,
|
|
1065
|
+
every syscall relative to ``dir_fd``, fsync before the replace, temp removed
|
|
1066
|
+
on any failure, no mode or xattrs inherited, POSIX by construction).
|
|
1067
|
+
|
|
1068
|
+
The one difference is the whole point: ``data`` lands byte-for-byte. No
|
|
1069
|
+
encode and no newline translation, so a payload carrying CRLF keeps CRLF and
|
|
1070
|
+
bytes that are not valid text in any codec survive the round trip. The
|
|
1071
|
+
anchored cohort needs this variant for the same reason the path-based one
|
|
1072
|
+
does — ``policy.write_mux_backend`` and the two frontmatter writers read
|
|
1073
|
+
bytes precisely to preserve a file's existing line endings, and a text-only
|
|
1074
|
+
anchored helper would have rewritten them (#593)."""
|
|
1075
|
+
_atomic_write_at(dir_fd, name, data, mode="wb", encoding=None)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _open_exclusive_at(dir_fd: int, prefix: str, name: str) -> tuple[int, str]:
|
|
1079
|
+
"""One ``mkstemp``'s worth of anchored staging: ``O_EXCL``-create
|
|
1080
|
+
``{prefix}<pid>.<random>.tmp`` relative to ``dir_fd`` and return the open fd
|
|
1081
|
+
with the name it landed at. The ``.tmp`` suffix keeps the temp out of
|
|
1082
|
+
``devcontract``'s ``*.md`` artifact scans, exactly as `_mkstemp_beside`'s
|
|
1083
|
+
suffix does.
|
|
1084
|
+
|
|
1085
|
+
``os.urandom``, not ``random``: this name is created in a directory a
|
|
1086
|
+
less-trusted writer can reach, and a predictable one lets them pre-create it
|
|
1087
|
+
and fail every record write (``O_EXCL`` turns the collision into a refusal
|
|
1088
|
+
rather than a clobber, so the harm is a stuck hint rather than a redirect —
|
|
1089
|
+
but an unguessable name removes even that, #591)."""
|
|
1090
|
+
for _ in range(_TMP_NAME_ATTEMPTS):
|
|
1091
|
+
tmp = f"{prefix}{os.getpid():x}.{os.urandom(4).hex()}.tmp"
|
|
1092
|
+
try:
|
|
1093
|
+
return os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dir_fd), tmp
|
|
1094
|
+
except FileExistsError:
|
|
1095
|
+
continue # astronomically unlikely; costs one more draw
|
|
1096
|
+
raise OSError(f"no free temp name beside {name!r} after {_TMP_NAME_ATTEMPTS} tries")
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _atomic_write_at(
|
|
1100
|
+
dir_fd: int, name: str, payload: str | bytes, *, mode: str, encoding: str | None
|
|
1101
|
+
) -> None:
|
|
1102
|
+
"""The shared body of the two anchored helpers above — see
|
|
1103
|
+
:func:`atomic_write_text_at` for the contract every step here implements.
|
|
1104
|
+
|
|
1105
|
+
``encoding`` doubles as the text/bytes discriminator, as it does in
|
|
1106
|
+
:func:`_atomic_write`: the text arm is opened with it plus ``newline=""``,
|
|
1107
|
+
the bytes arm with neither, because ``os.fdopen`` refuses both in binary mode
|
|
1108
|
+
and a byte-verbatim payload has nothing to translate anyway.
|
|
1109
|
+
|
|
1110
|
+
Staging walks :func:`_stage_shortening`'s ladder, the same one
|
|
1111
|
+
``_mkstemp_beside`` walks, so a target basename near ``NAME_MAX`` stages here
|
|
1112
|
+
exactly where the path-based writer stages it (#595) — without the ladder the
|
|
1113
|
+
confined adoption reintroduced the long-basename failure for every spec it
|
|
1114
|
+
moved onto this arm."""
|
|
1115
|
+
fd, tmp = _stage_shortening(name, lambda prefix: _open_exclusive_at(dir_fd, prefix, name))
|
|
1116
|
+
try:
|
|
1117
|
+
staged = (
|
|
1118
|
+
os.fdopen(fd, mode)
|
|
1119
|
+
if encoding is None
|
|
1120
|
+
else os.fdopen(fd, mode, encoding=encoding, newline="")
|
|
1121
|
+
)
|
|
1122
|
+
with staged as fh:
|
|
1123
|
+
fh.write(payload)
|
|
1124
|
+
fh.flush() # userspace buffer -> kernel, so there is something to sync
|
|
1125
|
+
os.fsync(fh.fileno())
|
|
1126
|
+
os.replace(tmp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
1127
|
+
except BaseException:
|
|
1128
|
+
with suppress(OSError):
|
|
1129
|
+
os.unlink(tmp, dir_fd=dir_fd)
|
|
1130
|
+
raise
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _refuse_unwritable_target_at(dir_fd: int, name: str) -> None:
|
|
1134
|
+
""":func:`_refuse_unwritable_target`, asked relative to an open descriptor.
|
|
1135
|
+
|
|
1136
|
+
The point of the anchored path is that it never names a path again, so the
|
|
1137
|
+
probe must not either: ``dir_fd=`` asks about the entry in the directory that
|
|
1138
|
+
was actually walked, not about a name a concurrent writer may since have
|
|
1139
|
+
redirected. ``O_NOFOLLOW`` always, because the write this guards replaces the
|
|
1140
|
+
NAME — the same reasoning as the no-follow arm there, ``ELOOP`` included.
|
|
1141
|
+
``O_NONBLOCK`` for the reason the path-based probe gives: a reader-less FIFO
|
|
1142
|
+
planted at the name answers ``ENXIO`` instead of wedging the probe forever.
|
|
1143
|
+
POSIX by construction: only :data:`DIR_FD_ANCHORED_WRITES` reaches here."""
|
|
1144
|
+
try:
|
|
1145
|
+
fd = os.open(name, os.O_WRONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dir_fd)
|
|
1146
|
+
except PermissionError:
|
|
1147
|
+
raise # the refusal this flag exists for
|
|
1148
|
+
except OSError:
|
|
1149
|
+
return # missing, a link at the name, or something the write itself reports
|
|
1150
|
+
os.close(fd)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def atomic_write_text_confined(
|
|
1154
|
+
path: Path, text: str, *, confine_root: Path, require_writable_target: bool = False
|
|
1155
|
+
) -> None:
|
|
1156
|
+
""":func:`atomic_write_text`, refusing to write through a redirected PARENT.
|
|
1157
|
+
|
|
1158
|
+
``follow_symlinks=False`` stops a link planted at the file itself and nothing
|
|
1159
|
+
more: ``mkstemp(dir=...)`` and ``os.replace``'s destination are still ordinary
|
|
1160
|
+
path lookups, so a link planted at any DIRECTORY above the target lands both
|
|
1161
|
+
the temp and the published file wherever that link points, and the no-follow
|
|
1162
|
+
bought nothing (#593). ``mkdir(parents=True, exist_ok=True)`` accepts a
|
|
1163
|
+
symlink-to-a-directory, so a planted parent survives the setup step the
|
|
1164
|
+
callers run first. This closes that: the parent is established once, and the
|
|
1165
|
+
write is aimed at what was established rather than at the name again.
|
|
1166
|
+
|
|
1167
|
+
A separate function rather than a ``confine_root=`` keyword on the plain
|
|
1168
|
+
writer. That keyword would admit a combination with no meaning — confined AND
|
|
1169
|
+
following symlinks — it would change ``_atomic_write`` for the dozen callers
|
|
1170
|
+
who did not ask for it, and separate names keep the adopted cohort greppable.
|
|
1171
|
+
|
|
1172
|
+
``path`` must be **lexically** under ``confine_root`` (``relative_to``, no
|
|
1173
|
+
resolve — the confined spelling is the caller's own construction, and
|
|
1174
|
+
resolving it would consult the very links this refuses) **and carry no ``..``
|
|
1175
|
+
below it**: ``is_relative_to`` is a prefix test that ``root/specs/../../x``
|
|
1176
|
+
passes while naming a path outside the root, and ``..`` is a real directory
|
|
1177
|
+
entry the anchored walk would otherwise climb straight back out through
|
|
1178
|
+
(``O_NOFOLLOW`` has no opinion on dot-dot). Either refusal raises before
|
|
1179
|
+
anything is walked or staged. ``path.parent`` must
|
|
1180
|
+
already EXIST: a confinement walk cannot vouch for a component that is not
|
|
1181
|
+
there, so every adopter mkdirs or gates first.
|
|
1182
|
+
|
|
1183
|
+
POSIX walks the components with :func:`open_dir_confined` and writes through
|
|
1184
|
+
the descriptor that walk produced, which a later swap of any name along the
|
|
1185
|
+
way no longer reaches. Win32 has no ``*at()`` family, so it degrades to
|
|
1186
|
+
:func:`path_is_confined` plus a no-follow write — check-then-write, which
|
|
1187
|
+
removes the standing redirect but leaves the window between the check and the
|
|
1188
|
+
write open (the precedent, and the same documented residual, as
|
|
1189
|
+
``tui/launch._record_ctl_window``). Either arm refuses by raising
|
|
1190
|
+
:class:`UnconfinedWriteError`, which is an ``OSError``.
|
|
1191
|
+
|
|
1192
|
+
Mode and xattrs are NEVER inherited — the file lands at ``0600``, which is
|
|
1193
|
+
exactly what ``follow_symlinks=False`` already gives this cohort, so adopting
|
|
1194
|
+
this changes no file's permissions. The anchored arm writes UTF-8 with no
|
|
1195
|
+
newline translation (identity on POSIX, where the translating default writes
|
|
1196
|
+
``\n`` unchanged) and the win32 arm keeps :func:`atomic_write_text`'s
|
|
1197
|
+
translating default, so on each platform the bytes that land are the ones
|
|
1198
|
+
that land today. A caller preserving a file's existing line endings wants
|
|
1199
|
+
:func:`atomic_write_bytes_confined`, as it wants the bytes writer today.
|
|
1200
|
+
|
|
1201
|
+
``require_writable_target`` behaves as it does in :func:`atomic_write_text`;
|
|
1202
|
+
on the anchored arm the probe is asked dir_fd-relative, never by path."""
|
|
1203
|
+
_atomic_write_confined(
|
|
1204
|
+
path,
|
|
1205
|
+
text,
|
|
1206
|
+
mode="w",
|
|
1207
|
+
encoding="utf-8",
|
|
1208
|
+
confine_root=confine_root,
|
|
1209
|
+
require_writable_target=require_writable_target,
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def atomic_write_bytes_confined(
|
|
1214
|
+
path: Path, data: bytes, *, confine_root: Path, require_writable_target: bool = False
|
|
1215
|
+
) -> None:
|
|
1216
|
+
""":func:`atomic_write_text_confined`'s byte-exact sibling, whose docstring
|
|
1217
|
+
carries the shared contract (lexical ``confine_root`` gate, anchored parent on
|
|
1218
|
+
POSIX and the documented check-then-write degrade on win32,
|
|
1219
|
+
:class:`UnconfinedWriteError` on refusal, an existing parent required, ``0600``
|
|
1220
|
+
with no mode or xattrs inherited).
|
|
1221
|
+
|
|
1222
|
+
``data`` lands byte-for-byte on both arms: no encode, no newline translation.
|
|
1223
|
+
That is what the byte-verbatim writers in this cohort exist for — they read
|
|
1224
|
+
bytes precisely so a CRLF file keeps its line endings."""
|
|
1225
|
+
_atomic_write_confined(
|
|
1226
|
+
path,
|
|
1227
|
+
data,
|
|
1228
|
+
mode="wb",
|
|
1229
|
+
encoding=None,
|
|
1230
|
+
confine_root=confine_root,
|
|
1231
|
+
require_writable_target=require_writable_target,
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _atomic_write_confined(
|
|
1236
|
+
path: Path,
|
|
1237
|
+
payload: str | bytes,
|
|
1238
|
+
*,
|
|
1239
|
+
mode: str,
|
|
1240
|
+
encoding: str | None,
|
|
1241
|
+
confine_root: Path,
|
|
1242
|
+
require_writable_target: bool,
|
|
1243
|
+
) -> None:
|
|
1244
|
+
"""The shared body of the two confined helpers above — see
|
|
1245
|
+
:func:`atomic_write_text_confined` for the contract every step implements.
|
|
1246
|
+
|
|
1247
|
+
The ``has_parent_ref`` refusal is the debt :func:`path_is_confined`'s
|
|
1248
|
+
docstring assigns to "a caller building ``target`` out of untrusted parts":
|
|
1249
|
+
``is_relative_to`` is a lexical PREFIX test, so ``root/specs/../../outside``
|
|
1250
|
+
passes it while naming a path outside the root. Neither arm below catches
|
|
1251
|
+
that on its own — ``..`` is a real directory entry, so the anchored walk
|
|
1252
|
+
opens it (``O_NOFOLLOW`` has no opinion on dot-dot) and climbs back OUT of
|
|
1253
|
+
the root, and the win32 walk ``lstat``s through it the same way. Checked over
|
|
1254
|
+
the RELATIVE part only: the operator chooses ``confine_root``'s own spelling,
|
|
1255
|
+
and the components below it are the session-reachable half."""
|
|
1256
|
+
if not path.is_relative_to(confine_root):
|
|
1257
|
+
raise UnconfinedWriteError(f"{path} is not under {confine_root}")
|
|
1258
|
+
if has_parent_ref(path.relative_to(confine_root)):
|
|
1259
|
+
raise UnconfinedWriteError(f"{path} climbs back out of {confine_root} through '..'")
|
|
1260
|
+
unconfined = f"cannot reach {path.parent} from {confine_root} without a redirect"
|
|
1261
|
+
if DIR_FD_ANCHORED_WRITES:
|
|
1262
|
+
dir_fd = open_dir_confined(confine_root, path.parent)
|
|
1263
|
+
if dir_fd is None:
|
|
1264
|
+
raise UnconfinedWriteError(unconfined)
|
|
1265
|
+
try:
|
|
1266
|
+
if require_writable_target:
|
|
1267
|
+
_refuse_unwritable_target_at(dir_fd, path.name)
|
|
1268
|
+
_atomic_write_at(dir_fd, path.name, payload, mode=mode, encoding=encoding)
|
|
1269
|
+
finally:
|
|
1270
|
+
os.close(dir_fd)
|
|
1271
|
+
return
|
|
1272
|
+
if not path_is_confined(confine_root, path.parent):
|
|
1273
|
+
raise UnconfinedWriteError(unconfined)
|
|
1274
|
+
_atomic_write(
|
|
1275
|
+
path,
|
|
1276
|
+
payload,
|
|
1277
|
+
mode=mode,
|
|
1278
|
+
encoding=encoding,
|
|
1279
|
+
follow_symlinks=False,
|
|
1280
|
+
require_writable_target=require_writable_target,
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def create_exclusive_confined(path: Path, *, confine_root: Path) -> int:
|
|
1285
|
+
"""``os.open(path, O_WRONLY | O_CREAT | O_EXCL, 0o600)``, the parent reached
|
|
1286
|
+
the way the confined writers reach it (#593). Returns the open fd, which the
|
|
1287
|
+
caller owns; raises ``FileExistsError`` when the name is already taken — a
|
|
1288
|
+
planted link included, since ``O_EXCL`` never dereferences the final
|
|
1289
|
+
component — and ``UnconfinedWriteError`` when the parent cannot be vouched
|
|
1290
|
+
for (out of root, a ``..`` in the relative part, or a redirect at any
|
|
1291
|
+
component below ``confine_root``).
|
|
1292
|
+
|
|
1293
|
+
Exists for exclusive-create ARBITRATION files (``runs._create_stop_request``),
|
|
1294
|
+
where "is one pending?" and "lodge mine" must stay a single atomic step
|
|
1295
|
+
against the destination name. The temp-and-replace confined writers cannot
|
|
1296
|
+
express that — a replace is unconditional by design — so this shares only
|
|
1297
|
+
their parent walk, not their staging. On POSIX the create is anchored at the
|
|
1298
|
+
walked descriptor; win32 has no ``*at()`` family and degrades to the same
|
|
1299
|
+
documented check-then-create as :func:`atomic_write_text_confined`'s
|
|
1300
|
+
fallback arm."""
|
|
1301
|
+
if not path.is_relative_to(confine_root):
|
|
1302
|
+
raise UnconfinedWriteError(f"{path} is not under {confine_root}")
|
|
1303
|
+
if has_parent_ref(path.relative_to(confine_root)):
|
|
1304
|
+
raise UnconfinedWriteError(f"{path} climbs back out of {confine_root} through '..'")
|
|
1305
|
+
unconfined = f"cannot reach {path.parent} from {confine_root} without a redirect"
|
|
1306
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
1307
|
+
if DIR_FD_ANCHORED_WRITES:
|
|
1308
|
+
dir_fd = open_dir_confined(confine_root, path.parent)
|
|
1309
|
+
if dir_fd is None:
|
|
1310
|
+
raise UnconfinedWriteError(unconfined)
|
|
1311
|
+
try:
|
|
1312
|
+
return os.open(path.name, flags, 0o600, dir_fd=dir_fd)
|
|
1313
|
+
finally:
|
|
1314
|
+
os.close(dir_fd)
|
|
1315
|
+
if not path_is_confined(confine_root, path.parent):
|
|
1316
|
+
raise UnconfinedWriteError(unconfined)
|
|
1317
|
+
return os.open(path, flags, 0o600)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def retrying_unlink(path: Path) -> None:
|
|
1321
|
+
"""``path.unlink()`` with the same win32 retry as :func:`atomic_replace`.
|
|
1322
|
+
|
|
1323
|
+
Windows denies a *delete* against an open handle exactly as it denies a
|
|
1324
|
+
rename-over, so the second half of a staged move is no safer than the first:
|
|
1325
|
+
an AV/indexer scanning the just-written source file fails the unlink. Pair the
|
|
1326
|
+
two whenever a move must not half-apply."""
|
|
1327
|
+
_retry_on_sharing_violation(path.unlink)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
@contextmanager
|
|
1331
|
+
def file_lock(path: Path, *, blocking: bool = True) -> Iterator[None]:
|
|
1332
|
+
"""Exclusive OS advisory lock on ``path`` (created if missing), released on
|
|
1333
|
+
exit — and by the kernel when the holder dies, so a crashed process never
|
|
1334
|
+
wedges the lock (no stale-lockfile scheme to clean up). ``blocking=False``
|
|
1335
|
+
raises ``OSError`` at once when the lock is already held, giving tests a
|
|
1336
|
+
deterministic exclusion probe instead of a sleep-based negative assertion.
|
|
1337
|
+
|
|
1338
|
+
Lock a dedicated sibling file, never data that is swapped via
|
|
1339
|
+
:func:`atomic_replace` — the lock rides the open fd's inode, and a replace
|
|
1340
|
+
would swap that inode out from under later acquirers. ``fcntl.flock`` on
|
|
1341
|
+
POSIX; ``msvcrt.locking`` on Windows, where the blocking mode's built-in
|
|
1342
|
+
~10 s retry bounds the wait and surfaces contention as ``OSError``.
|
|
1343
|
+
|
|
1344
|
+
THE WAIT IS PLATFORM-ASYMMETRIC, and a caller has to decide what that means
|
|
1345
|
+
for it: POSIX blocks indefinitely, Windows gives up after ~10 s and raises.
|
|
1346
|
+
This docstring used to add "holders only do brief file I/O" — true when the
|
|
1347
|
+
only consumers were tests, and falsified by the first production caller
|
|
1348
|
+
(``install._worktree_local_exclude``, #384), which holds it across a
|
|
1349
|
+
multi-step git transaction of roughly seven ``git`` spawns, each bounded by
|
|
1350
|
+
``[limits] git_timeout_s``. So a Windows acquirer can genuinely time out
|
|
1351
|
+
under contention rather than only under a deadlock. Hold it for as short a
|
|
1352
|
+
span as correctness allows, and handle the ``OSError`` from acquisition.
|
|
1353
|
+
|
|
1354
|
+
OWNER-ONLY, AND DELIBERATELY NOT MADE TO WORK ACROSS OS USERS. A repository
|
|
1355
|
+
shared between OS users is not a supported configuration (maintainer
|
|
1356
|
+
decision, #384); ``install._shield_shared_repository`` refuses one up front,
|
|
1357
|
+
above the shield's acquisition of this lock, so no lock file is created there
|
|
1358
|
+
at all. That gate is where the case is handled — not here. Three "fixes"
|
|
1359
|
+
belong to it rather than to this function, and each is worse than it looks:
|
|
1360
|
+
widening the mode (from the umask, from ``.git``'s own bits, from a
|
|
1361
|
+
``core.sharedRepository`` read) hands a peer write access to a file this
|
|
1362
|
+
process is relying on; falling back to ``O_RDONLY`` when the open fails
|
|
1363
|
+
rescues only the modes that happen to grant group read; and unlinking a
|
|
1364
|
+
badly-moded lock to recreate it is the actively dangerous one — a lock
|
|
1365
|
+
another process currently HOLDS would be replaced by a new inode, ``flock``
|
|
1366
|
+
exclusion rides the inode, and both processes would then believe they hold
|
|
1367
|
+
it, rebuilding the concurrency bug this lock was added to prevent.
|
|
1368
|
+
|
|
1369
|
+
The explicit ``0o600`` states that policy rather than leaving it to the
|
|
1370
|
+
caller's umask, which would make the mode a property of whoever provisioned
|
|
1371
|
+
first (measured: umask 022 gives 0o755, umask 077 gives 0o700 — both
|
|
1372
|
+
arbitrary). It is a ceiling, not a floor: ``os.open``'s mode is still masked,
|
|
1373
|
+
so an unusual umask can only narrow it further, never widen it.
|
|
1374
|
+
"""
|
|
1375
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1376
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
1377
|
+
try:
|
|
1378
|
+
if sys.platform == "win32":
|
|
1379
|
+
import msvcrt
|
|
1380
|
+
|
|
1381
|
+
# Locks 1 byte at the current position — 0 on a fresh fd.
|
|
1382
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
|
|
1383
|
+
else:
|
|
1384
|
+
import fcntl
|
|
1385
|
+
|
|
1386
|
+
fcntl.flock(fd, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
1387
|
+
try:
|
|
1388
|
+
yield
|
|
1389
|
+
finally:
|
|
1390
|
+
if sys.platform == "win32":
|
|
1391
|
+
import msvcrt
|
|
1392
|
+
|
|
1393
|
+
# Unlock the same 1-byte region before close; POSIX flock is
|
|
1394
|
+
# released by the close itself.
|
|
1395
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
1396
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
1397
|
+
finally:
|
|
1398
|
+
os.close(fd)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _is_reserved_basename(seg: str) -> bool:
|
|
1402
|
+
"""True if ``seg``'s basename (before the first dot, trailing spaces trimmed —
|
|
1403
|
+
``CON .txt`` counts) is a Windows reserved device name."""
|
|
1404
|
+
stem = seg.split(".", 1)[0].rstrip(" ")
|
|
1405
|
+
return stem.upper() in _RESERVED_BASENAMES
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _digest_suffix(name: str) -> str:
|
|
1409
|
+
"""The ``-<hex8>`` collision suffix both sanitizers append to changed input."""
|
|
1410
|
+
digest = hashlib.sha1(
|
|
1411
|
+
name.encode("utf-8", "surrogatepass"),
|
|
1412
|
+
usedforsecurity=False, # collision-resistance suffix, not a credential
|
|
1413
|
+
).hexdigest()
|
|
1414
|
+
return "-" + digest[:8]
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def safe_segment(name: str) -> str:
|
|
1418
|
+
"""Coerce ``name`` into a single Windows-legal path segment, returning legal
|
|
1419
|
+
input unchanged (identity for clean keys — the common case, e.g. a story key
|
|
1420
|
+
like ``3-2-digest-delivery``).
|
|
1421
|
+
|
|
1422
|
+
Replaces the reserved characters ``<>:"/\\|?*`` and control chars with ``_``,
|
|
1423
|
+
strips trailing dots and spaces (Windows silently drops them), caps the length,
|
|
1424
|
+
and defuses the reserved device basenames (CON, PRN, AUX, NUL, COM0-9, LPT0-9
|
|
1425
|
+
and their superscript ¹²³ forms — case-insensitive, with or without an
|
|
1426
|
+
extension). Whenever anything is changed a short digest of the raw input is
|
|
1427
|
+
appended, giving practical (probabilistic, not absolute) collision resistance
|
|
1428
|
+
between distinct raw names: clean-key identity is the stronger contract, so a
|
|
1429
|
+
clean name that happens to look like a sanitized-plus-digest name passes
|
|
1430
|
+
through verbatim, and case-insensitive NTFS collisions between clean names
|
|
1431
|
+
remain the caller's concern. Never raises."""
|
|
1432
|
+
cleaned = _ILLEGAL_SEGMENT_CHARS.sub("_", name).rstrip(". ")[:MAX_SEGMENT]
|
|
1433
|
+
if _is_reserved_basename(cleaned):
|
|
1434
|
+
cleaned = "_" + cleaned
|
|
1435
|
+
if not cleaned:
|
|
1436
|
+
cleaned = "_"
|
|
1437
|
+
if cleaned == name:
|
|
1438
|
+
return name # already a legal segment — keep it byte-identical
|
|
1439
|
+
suffix = _digest_suffix(name)
|
|
1440
|
+
return cleaned[: MAX_SEGMENT - len(suffix)] + suffix
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _is_clean_ref_segment(seg: str) -> bool:
|
|
1444
|
+
"""True if ``seg`` already satisfies git's rules for one ref component.
|
|
1445
|
+
|
|
1446
|
+
Mirrors ``git check-ref-format``'s per-component checks. The length cap is
|
|
1447
|
+
ours, not git's: it keeps a branch segment in lockstep with the ``safe_segment``
|
|
1448
|
+
directory built from the same key."""
|
|
1449
|
+
return (
|
|
1450
|
+
bool(seg)
|
|
1451
|
+
and len(seg) <= MAX_SEGMENT
|
|
1452
|
+
and not _ILLEGAL_REF_CHARS.search(seg)
|
|
1453
|
+
and ".." not in seg
|
|
1454
|
+
and "@{" not in seg
|
|
1455
|
+
and seg != "@"
|
|
1456
|
+
and not seg.startswith(".")
|
|
1457
|
+
and not seg.endswith((".", ".lock"))
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def safe_ref_segment(name: str) -> str:
|
|
1462
|
+
"""Coerce ``name`` into a single git-ref-legal component, returning legal input
|
|
1463
|
+
unchanged (identity for clean keys — the common case, e.g. a story key like
|
|
1464
|
+
``3-2-digest-delivery`` or an auto-generated run id).
|
|
1465
|
+
|
|
1466
|
+
Same contract as :func:`safe_segment` — identity for clean input, a short digest
|
|
1467
|
+
of the raw name appended whenever anything changed, never raises — but git's
|
|
1468
|
+
alphabet, not Windows': replaces control chars, space, DEL and ``~^:?*[\\/`` with
|
|
1469
|
+
``_``, rewrites ``..`` → ``__`` and ``@{`` → ``_{``, escapes a leading ``.``, and
|
|
1470
|
+
caps the length. Trailing ``.`` and trailing ``.lock`` are ref-illegal but need no
|
|
1471
|
+
rewrite: they only reach the coercion path, and the ``-<hex8>`` suffix appended
|
|
1472
|
+
there is itself the fix. A lone ``@`` is coerced to ``_`` even though git only
|
|
1473
|
+
forbids it as a whole ref name, so the contract holds for any caller.
|
|
1474
|
+
|
|
1475
|
+
A leading ``-`` is deliberately preserved: it is legal in a ref component, and
|
|
1476
|
+
the git porcelain's separate "branch name must not start with ``-``" check reads
|
|
1477
|
+
the whole name, which callers always prefix (``froid-loop/<run_id>/<segment>``).
|
|
1478
|
+
|
|
1479
|
+
Digest collision resistance is probabilistic, and clean-key identity is the
|
|
1480
|
+
stronger contract — so a clean name that happens to look sanitized-plus-digest
|
|
1481
|
+
passes through verbatim."""
|
|
1482
|
+
if _is_clean_ref_segment(name):
|
|
1483
|
+
return name # already a legal ref component — keep it byte-identical
|
|
1484
|
+
cleaned = _ILLEGAL_REF_CHARS.sub("_", name).replace("..", "__").replace("@{", "_{")
|
|
1485
|
+
if cleaned.startswith("."):
|
|
1486
|
+
cleaned = "_" + cleaned[1:]
|
|
1487
|
+
if not cleaned or cleaned == "@":
|
|
1488
|
+
cleaned = "_"
|
|
1489
|
+
suffix = _digest_suffix(name)
|
|
1490
|
+
return cleaned[: MAX_SEGMENT - len(suffix)] + suffix
|