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.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. 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