python-hwpx-automation 6.0.3__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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1694 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical fail-closed workspace-root resolution for local MCP paths."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import sys
13
+ import tempfile
14
+ from dataclasses import dataclass, replace
15
+ from functools import lru_cache
16
+ from pathlib import Path, PurePath, PureWindowsPath
17
+ from typing import Iterable
18
+ from urllib.parse import unquote, urlsplit
19
+
20
+ from .configuration import canonical_env_name, legacy_env_name
21
+
22
+
23
+ WORKSPACE_ROOTS_ENV = canonical_env_name("WORKSPACE_ROOTS")
24
+ LEGACY_WORKSPACE_ROOTS_ENV = legacy_env_name("WORKSPACE_ROOTS")
25
+ LEGACY_SANDBOX_ROOT_ENV = legacy_env_name("SANDBOX_ROOT")
26
+
27
+
28
+ class WorkspaceConfigurationError(RuntimeError):
29
+ """The host did not provide a usable, bounded workspace root."""
30
+
31
+ code = "WORKSPACE_ROOT_INVALID"
32
+
33
+
34
+ class WorkspacePathError(PermissionError):
35
+ """A requested path escaped or violated the configured workspace policy."""
36
+
37
+ def __init__(self, message: str, *, code: str, reason: str) -> None:
38
+ super().__init__(message)
39
+ self.code = code
40
+ self.reason = reason
41
+
42
+ def safe_details(self) -> dict[str, str]:
43
+ return {"reason": self.reason}
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class WorkspaceOutputGuard:
48
+ """Identity-bound capability for one authorized output parent."""
49
+
50
+ path: Path
51
+ root: Path
52
+ root_device: int
53
+ root_inode: int
54
+ parent_device: int
55
+ parent_inode: int
56
+ target_existed: bool
57
+ target_device: int | None
58
+ target_inode: int | None
59
+ target_digest: str | None
60
+ target_mode: int | None
61
+ owned_parent_directories: tuple[tuple[Path, int, int], ...] = ()
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class WorkspaceMissingParentGuard:
66
+ """Identity-bound precondition for an output whose parent is absent."""
67
+
68
+ path: Path
69
+ root: Path
70
+ root_device: int
71
+ root_inode: int
72
+ anchor: Path
73
+ anchor_device: int
74
+ anchor_inode: int
75
+ missing_parts: tuple[str, ...]
76
+
77
+
78
+ def _digest_descriptor(descriptor: int, *, copy_to: int | None = None) -> str:
79
+ """Hash one descriptor, optionally copying it to another descriptor."""
80
+
81
+ digest = hashlib.sha256()
82
+ os.lseek(descriptor, 0, os.SEEK_SET)
83
+ if copy_to is not None:
84
+ os.lseek(copy_to, 0, os.SEEK_SET)
85
+ os.ftruncate(copy_to, 0)
86
+ while True:
87
+ chunk = os.read(descriptor, 1024 * 1024)
88
+ if not chunk:
89
+ break
90
+ digest.update(chunk)
91
+ if copy_to is not None:
92
+ view = memoryview(chunk)
93
+ while view:
94
+ written = os.write(copy_to, view)
95
+ view = view[written:]
96
+ if copy_to is not None:
97
+ os.fsync(copy_to)
98
+ return digest.hexdigest()
99
+
100
+
101
+ def _snapshot_target(
102
+ path: Path,
103
+ ) -> tuple[bool, int | None, int | None, str | None, int | None]:
104
+ """Capture the exact regular file currently occupying an output name."""
105
+
106
+ flags = (
107
+ os.O_RDONLY
108
+ | getattr(os, "O_NOFOLLOW", 0)
109
+ | getattr(os, "O_NONBLOCK", 0)
110
+ )
111
+ try:
112
+ descriptor = os.open(path, flags)
113
+ except FileNotFoundError:
114
+ return False, None, None, None, None
115
+ except OSError as exc:
116
+ raise WorkspacePathError(
117
+ "output target could not be captured safely",
118
+ code="WORKSPACE_PATH_INVALID",
119
+ reason="output_target_unavailable",
120
+ ) from exc
121
+ try:
122
+ before = os.fstat(descriptor)
123
+ if not stat.S_ISREG(before.st_mode):
124
+ raise WorkspacePathError(
125
+ "output target is not a regular file",
126
+ code="WORKSPACE_PATH_INVALID",
127
+ reason="output_target_not_regular",
128
+ )
129
+ digest = _digest_descriptor(descriptor)
130
+ after = os.fstat(descriptor)
131
+ if (
132
+ before.st_dev,
133
+ before.st_ino,
134
+ before.st_size,
135
+ before.st_mtime_ns,
136
+ ) != (
137
+ after.st_dev,
138
+ after.st_ino,
139
+ after.st_size,
140
+ after.st_mtime_ns,
141
+ ):
142
+ raise WorkspacePathError(
143
+ "output target changed while it was captured",
144
+ code="WORKSPACE_PATH_CHANGED",
145
+ reason="output_target_changed",
146
+ )
147
+ return (
148
+ True,
149
+ after.st_dev,
150
+ after.st_ino,
151
+ digest,
152
+ stat.S_IMODE(after.st_mode),
153
+ )
154
+ finally:
155
+ os.close(descriptor)
156
+
157
+
158
+ def _exchange_entries(parent_fd: int, first: str, second: str) -> None:
159
+ """Atomically exchange two descriptor-relative names on macOS/Linux."""
160
+
161
+ libc = ctypes.CDLL(None, use_errno=True)
162
+ first_raw = os.fsencode(first)
163
+ second_raw = os.fsencode(second)
164
+ if sys.platform == "darwin":
165
+ function = getattr(libc, "renameatx_np", None)
166
+ if function is None: # pragma: no cover - supported macOS invariant
167
+ raise WorkspaceConfigurationError(
168
+ "macOS renameatx_np is required for atomic output CAS"
169
+ )
170
+ function.argtypes = [
171
+ ctypes.c_int,
172
+ ctypes.c_char_p,
173
+ ctypes.c_int,
174
+ ctypes.c_char_p,
175
+ ctypes.c_uint,
176
+ ]
177
+ function.restype = ctypes.c_int
178
+ # RENAME_SWAP | RENAME_NOFOLLOW_ANY
179
+ result = function(parent_fd, first_raw, parent_fd, second_raw, 0x12)
180
+ elif sys.platform.startswith("linux"):
181
+ function = getattr(libc, "renameat2", None)
182
+ if function is None: # pragma: no cover - old libc/runtime
183
+ raise WorkspaceConfigurationError(
184
+ "Linux renameat2 is required for atomic output CAS"
185
+ )
186
+ function.argtypes = [
187
+ ctypes.c_int,
188
+ ctypes.c_char_p,
189
+ ctypes.c_int,
190
+ ctypes.c_char_p,
191
+ ctypes.c_uint,
192
+ ]
193
+ function.restype = ctypes.c_int
194
+ # RENAME_EXCHANGE
195
+ result = function(parent_fd, first_raw, parent_fd, second_raw, 0x2)
196
+ else:
197
+ raise WorkspaceConfigurationError(
198
+ "atomic output replacement requires macOS renameatx_np or Linux renameat2"
199
+ )
200
+ if result != 0:
201
+ error_number = ctypes.get_errno()
202
+ raise OSError(error_number, os.strerror(error_number))
203
+
204
+
205
+ @lru_cache(maxsize=1)
206
+ def _descriptor_cas_supported() -> bool:
207
+ """Return whether this host exposes the atomic exchange primitive we use."""
208
+
209
+ if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
210
+ return False
211
+ libc = ctypes.CDLL(None)
212
+ if sys.platform == "darwin":
213
+ return getattr(libc, "renameatx_np", None) is not None
214
+ if sys.platform.startswith("linux"):
215
+ return getattr(libc, "renameat2", None) is not None
216
+ return False
217
+
218
+
219
+ def _relative_file_snapshot(
220
+ parent_fd: int,
221
+ name: str,
222
+ ) -> tuple[int, int, str, int]:
223
+ """Return identity, digest, and mode for a no-follow regular file."""
224
+
225
+ flags = (
226
+ os.O_RDONLY
227
+ | getattr(os, "O_NOFOLLOW", 0)
228
+ | getattr(os, "O_NONBLOCK", 0)
229
+ )
230
+ descriptor = os.open(name, flags, dir_fd=parent_fd)
231
+ try:
232
+ before = os.fstat(descriptor)
233
+ if not stat.S_ISREG(before.st_mode):
234
+ raise WorkspacePathError(
235
+ "authorized output target is no longer a regular file",
236
+ code="WORKSPACE_PATH_CHANGED",
237
+ reason="output_target_changed",
238
+ )
239
+ digest = _digest_descriptor(descriptor)
240
+ after = os.fstat(descriptor)
241
+ if (
242
+ before.st_dev,
243
+ before.st_ino,
244
+ before.st_size,
245
+ before.st_mtime_ns,
246
+ ) != (
247
+ after.st_dev,
248
+ after.st_ino,
249
+ after.st_size,
250
+ after.st_mtime_ns,
251
+ ):
252
+ raise WorkspacePathError(
253
+ "authorized output target changed while it was read",
254
+ code="WORKSPACE_PATH_CHANGED",
255
+ reason="output_target_changed",
256
+ )
257
+ return (
258
+ after.st_dev,
259
+ after.st_ino,
260
+ digest,
261
+ stat.S_IMODE(after.st_mode),
262
+ )
263
+ finally:
264
+ os.close(descriptor)
265
+
266
+
267
+ def _split_roots(raw: str) -> list[str]:
268
+ value = raw.strip()
269
+ if not value:
270
+ raise WorkspaceConfigurationError(
271
+ f"{WORKSPACE_ROOTS_ENV} is set but contains no workspace roots"
272
+ )
273
+ if value.startswith("["):
274
+ try:
275
+ decoded = json.loads(value)
276
+ except json.JSONDecodeError as exc:
277
+ raise WorkspaceConfigurationError(
278
+ f"{WORKSPACE_ROOTS_ENV} must be a JSON string array or {os.pathsep!r}-separated paths"
279
+ ) from exc
280
+ if not isinstance(decoded, list) or not all(
281
+ isinstance(item, str) and item.strip() for item in decoded
282
+ ):
283
+ raise WorkspaceConfigurationError(
284
+ f"{WORKSPACE_ROOTS_ENV} JSON value must be a non-empty string array"
285
+ )
286
+ return [item.strip() for item in decoded]
287
+ return [item.strip() for item in value.split(os.pathsep) if item.strip()]
288
+
289
+
290
+ def _normalize_roots(values: Iterable[str | os.PathLike[str]]) -> tuple[Path, ...]:
291
+ roots: list[Path] = []
292
+ for value in values:
293
+ raw = Path(value).expanduser()
294
+ try:
295
+ root = raw.resolve(strict=True)
296
+ except (FileNotFoundError, OSError) as exc:
297
+ raise WorkspaceConfigurationError(
298
+ "an authorized workspace root does not exist or cannot be resolved"
299
+ ) from exc
300
+ if not root.is_dir():
301
+ raise WorkspaceConfigurationError("an authorized workspace root is not a directory")
302
+ if root == Path(root.anchor):
303
+ raise WorkspaceConfigurationError(
304
+ "a filesystem root cannot be authorized as an HWPX workspace"
305
+ )
306
+ if root not in roots:
307
+ roots.append(root)
308
+ if not roots:
309
+ raise WorkspaceConfigurationError("at least one authorized workspace root is required")
310
+ return tuple(roots)
311
+
312
+
313
+ def _normalize_path_input(
314
+ value: str | os.PathLike[str], *, windows: bool | None = None
315
+ ) -> str:
316
+ """Normalize common local-path forms before workspace authorization.
317
+
318
+ Ordinary filesystem path names are preserved byte-for-byte: only explicit
319
+ transport decoration (surrounding quotes and ``file:`` URIs) is unwrapped.
320
+ A path such as ``" report.hwpx "`` with meaningful surrounding whitespace is
321
+ therefore never silently redirected to a different, unspaced file.
322
+ """
323
+
324
+ text = os.fspath(value)
325
+
326
+ # Unwrap surrounding quotes only. Whitespace outside the quote wrapper is
327
+ # transport decoration, while the quoted payload itself is preserved.
328
+ stripped = text.strip()
329
+ if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in {"'", '"'}:
330
+ text = stripped[1:-1]
331
+
332
+ # Only treat the input as a URI when it actually carries the ``file`` scheme.
333
+ # Running URI parsing over ordinary paths is what previously mangled valid
334
+ # names, so unscheme'd input flows through untouched.
335
+ if text[:5].lower() == "file:":
336
+ try:
337
+ parsed = urlsplit(text)
338
+ except ValueError as exc:
339
+ # A malformed authority (e.g. ``file://[invalid]/x``) makes urlsplit
340
+ # raise; map it onto the typed workspace error contract instead of
341
+ # leaking an untyped parser exception, without echoing the input.
342
+ raise WorkspacePathError(
343
+ "workspace path is not a valid file URI",
344
+ code="WORKSPACE_PATH_INVALID",
345
+ reason="malformed_file_uri",
346
+ ) from exc
347
+ path = unquote(parsed.path)
348
+ authority = parsed.netloc
349
+ # An empty or ``localhost`` authority denotes the local host: resolve to
350
+ # the plain path. A non-local authority names a remote host, which is
351
+ # not an addressable local workspace path, so reject it explicitly
352
+ # rather than silently localizing or mangling it into a UNC reference.
353
+ if authority and authority.lower() != "localhost":
354
+ raise WorkspacePathError(
355
+ "workspace path names a non-local file URI authority",
356
+ code="WORKSPACE_PATH_INVALID",
357
+ reason="non_local_file_uri_authority",
358
+ )
359
+ text = path
360
+
361
+ use_windows = os.name == "nt" if windows is None else windows
362
+ if use_windows:
363
+ if len(text) >= 3 and text[0] == "/" and text[1].isalpha() and text[2] == ":":
364
+ text = text[1:]
365
+ text = text.replace("/", "\\")
366
+ return text
367
+
368
+
369
+ def _windows_system_root() -> PureWindowsPath | None:
370
+ """Return the Windows system directory to fence off, or ``None`` elsewhere."""
371
+
372
+ if os.name != "nt":
373
+ return None
374
+ return PureWindowsPath(os.environ.get("SystemRoot", r"C:\Windows"))
375
+
376
+
377
+ def _is_degenerate_cwd(
378
+ workspace: PurePath,
379
+ *,
380
+ system_root: PureWindowsPath | None,
381
+ ) -> bool:
382
+ """Return whether a cwd fallback root is an unusable degenerate location.
383
+
384
+ A degenerate root is the filesystem root itself (a path that is its own
385
+ parent), or — when *system_root* is provided — the Windows system directory
386
+ or a descendant of it. GUI MCP clients launch servers from such directories
387
+ (``/`` on macOS, ``C:\\Windows\\System32`` on Windows), which must never
388
+ become an implicit workspace root. Passing *system_root* explicitly keeps
389
+ the Windows rule testable on POSIX via ``PureWindowsPath``.
390
+ """
391
+
392
+ if workspace == workspace.parent:
393
+ return True
394
+ if system_root is not None:
395
+ candidate_parts = tuple(part.lower() for part in PureWindowsPath(workspace).parts)
396
+ system_parts = tuple(part.lower() for part in system_root.parts)
397
+ if system_parts and candidate_parts[: len(system_parts)] == system_parts:
398
+ return True
399
+ return False
400
+
401
+
402
+ @dataclass(frozen=True, slots=True)
403
+ class WorkspaceResolver:
404
+ """Resolve relative and absolute paths inside one or more authorized roots."""
405
+
406
+ roots: tuple[Path, ...]
407
+ source: str
408
+
409
+ @classmethod
410
+ def from_environment(cls, *, cwd: Path | None = None) -> "WorkspaceResolver":
411
+ explicit = os.environ.get(WORKSPACE_ROOTS_ENV)
412
+ if explicit is not None:
413
+ return cls(_normalize_roots(_split_roots(explicit)), WORKSPACE_ROOTS_ENV)
414
+
415
+ legacy_roots = os.environ.get(LEGACY_WORKSPACE_ROOTS_ENV)
416
+ if legacy_roots is not None:
417
+ return cls(
418
+ _normalize_roots(_split_roots(legacy_roots)),
419
+ LEGACY_WORKSPACE_ROOTS_ENV,
420
+ )
421
+
422
+ legacy = os.environ.get(LEGACY_SANDBOX_ROOT_ENV)
423
+ if legacy is not None:
424
+ if not legacy.strip():
425
+ raise WorkspaceConfigurationError(
426
+ f"{LEGACY_SANDBOX_ROOT_ENV} is set but empty"
427
+ )
428
+ return cls(_normalize_roots([legacy]), LEGACY_SANDBOX_ROOT_ENV)
429
+
430
+ process_workspace = cwd or Path.cwd()
431
+ if _is_degenerate_cwd(process_workspace, system_root=_windows_system_root()):
432
+ raise WorkspaceConfigurationError(
433
+ f"the process working directory {os.fspath(process_workspace)!r} is not a "
434
+ f"usable HWPX workspace root; set {WORKSPACE_ROOTS_ENV} to one or more "
435
+ "existing document directories "
436
+ f'(for example {WORKSPACE_ROOTS_ENV}="~/Documents" on macOS/Linux '
437
+ f'or {WORKSPACE_ROOTS_ENV}="C:\\hwpx" on Windows)'
438
+ )
439
+ return cls(_normalize_roots([process_workspace]), "process-cwd")
440
+
441
+ @classmethod
442
+ def from_roots(
443
+ cls,
444
+ roots: Iterable[str | os.PathLike[str]],
445
+ *,
446
+ source: str = "explicit",
447
+ ) -> "WorkspaceResolver":
448
+ return cls(_normalize_roots(roots), source)
449
+
450
+ @property
451
+ def primary_root(self) -> Path:
452
+ return self.roots[0]
453
+
454
+ def _candidate(self, value: str | os.PathLike[str]) -> Path:
455
+ text = _normalize_path_input(value)
456
+ if not text or not text.strip() or "\0" in text:
457
+ raise WorkspacePathError(
458
+ "workspace path must be a non-empty filesystem path",
459
+ code="WORKSPACE_PATH_INVALID",
460
+ reason="empty_or_invalid_path",
461
+ )
462
+ candidate = Path(text).expanduser()
463
+ return candidate if candidate.is_absolute() else self.primary_root / candidate
464
+
465
+ def _authorized_root(self, resolved: Path) -> Path | None:
466
+ for root in self.roots:
467
+ if resolved == root or root in resolved.parents:
468
+ return root
469
+ return None
470
+
471
+ def resolve(
472
+ self,
473
+ value: str | os.PathLike[str],
474
+ *,
475
+ must_exist: bool = True,
476
+ ) -> Path:
477
+ candidate = self._candidate(value)
478
+ try:
479
+ resolved = candidate.resolve(strict=must_exist)
480
+ except FileNotFoundError:
481
+ raise
482
+ except OSError as exc:
483
+ raise WorkspacePathError(
484
+ "workspace path could not be resolved safely",
485
+ code="WORKSPACE_PATH_INVALID",
486
+ reason="resolution_failed",
487
+ ) from exc
488
+
489
+ if self._authorized_root(resolved) is None:
490
+ raise WorkspacePathError(
491
+ "path is outside the authorized HWPX workspace roots",
492
+ code="WORKSPACE_OUTSIDE_ROOT",
493
+ reason="outside_authorized_roots",
494
+ )
495
+ return resolved
496
+
497
+ def resolve_output(
498
+ self,
499
+ value: str | os.PathLike[str],
500
+ *,
501
+ create_parents: bool = True,
502
+ ) -> Path:
503
+ resolved = self.resolve(value, must_exist=False)
504
+ if resolved.exists() and resolved.is_dir():
505
+ raise WorkspacePathError(
506
+ "output path identifies a directory, not a document",
507
+ code="WORKSPACE_PATH_INVALID",
508
+ reason="output_is_directory",
509
+ )
510
+ if create_parents:
511
+ resolved.parent.mkdir(parents=True, exist_ok=True)
512
+ parent = resolved.parent.resolve(strict=True)
513
+ if self._authorized_root(parent) is None:
514
+ raise WorkspacePathError(
515
+ "output parent escaped the authorized HWPX workspace roots",
516
+ code="WORKSPACE_SYMLINK_ESCAPE",
517
+ reason="output_parent_escape",
518
+ )
519
+ resolved = parent / resolved.name
520
+ return resolved
521
+
522
+ def capture_output(
523
+ self,
524
+ value: str | os.PathLike[str],
525
+ *,
526
+ create_parents: bool = True,
527
+ ) -> WorkspaceOutputGuard:
528
+ """Bind an output to its current root and parent directory identities."""
529
+
530
+ resolved = self.resolve_output(value, create_parents=create_parents)
531
+ try:
532
+ parent = resolved.parent.resolve(strict=True)
533
+ root = self._authorized_root(parent)
534
+ if root is None:
535
+ raise WorkspacePathError(
536
+ "output parent escaped the authorized HWPX workspace roots",
537
+ code="WORKSPACE_SYMLINK_ESCAPE",
538
+ reason="output_parent_escape",
539
+ )
540
+ parent_stat = os.stat(parent, follow_symlinks=False)
541
+ root_stat = os.stat(root, follow_symlinks=False)
542
+ if _descriptor_cas_supported():
543
+ relative_parent = parent.relative_to(root)
544
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
545
+ root_fd = os.open(root, directory_flags)
546
+ opened: list[int] = [root_fd]
547
+ parent_fd = root_fd
548
+ try:
549
+ observed_root = os.fstat(root_fd)
550
+ if (observed_root.st_dev, observed_root.st_ino) != (
551
+ root_stat.st_dev,
552
+ root_stat.st_ino,
553
+ ):
554
+ raise WorkspacePathError(
555
+ "workspace root changed while binding output",
556
+ code="WORKSPACE_PATH_CHANGED",
557
+ reason="workspace_root_changed",
558
+ )
559
+ for component in relative_parent.parts:
560
+ next_fd = os.open(
561
+ component,
562
+ directory_flags,
563
+ dir_fd=parent_fd,
564
+ )
565
+ opened.append(next_fd)
566
+ parent_fd = next_fd
567
+ observed_parent = os.fstat(parent_fd)
568
+ if (observed_parent.st_dev, observed_parent.st_ino) != (
569
+ parent_stat.st_dev,
570
+ parent_stat.st_ino,
571
+ ):
572
+ raise WorkspacePathError(
573
+ "output parent changed while binding output",
574
+ code="WORKSPACE_PATH_CHANGED",
575
+ reason="output_parent_changed",
576
+ )
577
+ try:
578
+ (
579
+ target_device,
580
+ target_inode,
581
+ target_digest,
582
+ target_mode,
583
+ ) = _relative_file_snapshot(parent_fd, resolved.name)
584
+ target_existed = True
585
+ except FileNotFoundError:
586
+ target_existed = False
587
+ target_device = None
588
+ target_inode = None
589
+ target_digest = None
590
+ target_mode = None
591
+ finally:
592
+ for descriptor in reversed(opened):
593
+ os.close(descriptor)
594
+ else:
595
+ (
596
+ target_existed,
597
+ target_device,
598
+ target_inode,
599
+ target_digest,
600
+ target_mode,
601
+ ) = _snapshot_target(parent / resolved.name)
602
+ except WorkspacePathError:
603
+ raise
604
+ except OSError as exc:
605
+ raise WorkspacePathError(
606
+ "output parent could not be bound safely",
607
+ code="WORKSPACE_PATH_INVALID",
608
+ reason="output_parent_unavailable",
609
+ ) from exc
610
+ if not stat.S_ISDIR(parent_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode):
611
+ raise WorkspacePathError(
612
+ "output parent is not a stable directory",
613
+ code="WORKSPACE_PATH_INVALID",
614
+ reason="output_parent_not_directory",
615
+ )
616
+ return WorkspaceOutputGuard(
617
+ path=parent / resolved.name,
618
+ root=root,
619
+ root_device=root_stat.st_dev,
620
+ root_inode=root_stat.st_ino,
621
+ parent_device=parent_stat.st_dev,
622
+ parent_inode=parent_stat.st_ino,
623
+ target_existed=target_existed,
624
+ target_device=target_device,
625
+ target_inode=target_inode,
626
+ target_digest=target_digest,
627
+ target_mode=target_mode,
628
+ )
629
+
630
+ def capture_output_precondition(
631
+ self,
632
+ value: str | os.PathLike[str],
633
+ ) -> WorkspaceOutputGuard | WorkspaceMissingParentGuard:
634
+ """Bind an output without creating any missing parent directories."""
635
+
636
+ try:
637
+ return self.capture_output(value, create_parents=False)
638
+ except WorkspacePathError as exc:
639
+ if exc.reason != "output_parent_unavailable":
640
+ raise
641
+
642
+ path = self.resolve(value, must_exist=False)
643
+ ancestor = path.parent
644
+ missing_reversed: list[str] = []
645
+ while True:
646
+ try:
647
+ anchor_stat = os.stat(ancestor, follow_symlinks=False)
648
+ except FileNotFoundError:
649
+ if ancestor == ancestor.parent: # pragma: no cover - bounded root
650
+ raise WorkspacePathError(
651
+ "output parent could not be bound safely",
652
+ code="WORKSPACE_PATH_INVALID",
653
+ reason="output_parent_unavailable",
654
+ ) from None
655
+ missing_reversed.append(ancestor.name)
656
+ ancestor = ancestor.parent
657
+ continue
658
+ break
659
+ if not stat.S_ISDIR(anchor_stat.st_mode):
660
+ raise WorkspacePathError(
661
+ "output parent anchor is not a directory",
662
+ code="WORKSPACE_PATH_INVALID",
663
+ reason="output_parent_not_directory",
664
+ )
665
+ root = self._authorized_root(ancestor)
666
+ if root is None:
667
+ raise WorkspacePathError(
668
+ "output parent escaped the authorized HWPX workspace roots",
669
+ code="WORKSPACE_SYMLINK_ESCAPE",
670
+ reason="output_parent_escape",
671
+ )
672
+ root_stat = os.stat(root, follow_symlinks=False)
673
+ missing_parts = tuple(reversed(missing_reversed))
674
+ if not missing_parts:
675
+ raise WorkspacePathError(
676
+ "output parent could not be bound safely",
677
+ code="WORKSPACE_PATH_INVALID",
678
+ reason="output_parent_unavailable",
679
+ )
680
+ return WorkspaceMissingParentGuard(
681
+ path=path,
682
+ root=root,
683
+ root_device=root_stat.st_dev,
684
+ root_inode=root_stat.st_ino,
685
+ anchor=ancestor,
686
+ anchor_device=anchor_stat.st_dev,
687
+ anchor_inode=anchor_stat.st_ino,
688
+ missing_parts=missing_parts,
689
+ )
690
+
691
+ def materialize_output_guard(
692
+ self,
693
+ precondition: WorkspaceOutputGuard | WorkspaceMissingParentGuard,
694
+ ) -> WorkspaceOutputGuard:
695
+ """Create only a previously bound missing parent chain and return its guard."""
696
+
697
+ if isinstance(precondition, WorkspaceOutputGuard):
698
+ return precondition
699
+
700
+ created: list[tuple[Path, int, int]] = []
701
+ try:
702
+ current = self.resolve(precondition.path, must_exist=False)
703
+ root_stat = os.stat(precondition.root, follow_symlinks=False)
704
+ anchor_stat = os.stat(precondition.anchor, follow_symlinks=False)
705
+ if (
706
+ current != precondition.path
707
+ or self._authorized_root(precondition.anchor) != precondition.root
708
+ or (root_stat.st_dev, root_stat.st_ino)
709
+ != (precondition.root_device, precondition.root_inode)
710
+ or (anchor_stat.st_dev, anchor_stat.st_ino)
711
+ != (precondition.anchor_device, precondition.anchor_inode)
712
+ or not stat.S_ISDIR(root_stat.st_mode)
713
+ or not stat.S_ISDIR(anchor_stat.st_mode)
714
+ ):
715
+ raise WorkspacePathError(
716
+ "authorized output parent changed before creation",
717
+ code="WORKSPACE_PATH_CHANGED",
718
+ reason="output_parent_changed",
719
+ )
720
+ parent = precondition.anchor
721
+ for component in precondition.missing_parts:
722
+ child = parent / component
723
+ try:
724
+ os.mkdir(child, 0o700)
725
+ except FileExistsError as exc:
726
+ raise WorkspacePathError(
727
+ "authorized output parent appeared before creation",
728
+ code="WORKSPACE_PATH_CHANGED",
729
+ reason="output_parent_changed",
730
+ ) from exc
731
+ child_stat = os.stat(child, follow_symlinks=False)
732
+ if not stat.S_ISDIR(child_stat.st_mode):
733
+ raise WorkspacePathError(
734
+ "created output parent is not a directory",
735
+ code="WORKSPACE_PATH_CHANGED",
736
+ reason="output_parent_changed",
737
+ )
738
+ created.append((child, child_stat.st_dev, child_stat.st_ino))
739
+ parent = child
740
+ guard = self.capture_output(precondition.path, create_parents=False)
741
+ expected_parent = created[-1]
742
+ if (
743
+ guard.path != precondition.path
744
+ or guard.root != precondition.root
745
+ or (guard.root_device, guard.root_inode)
746
+ != (precondition.root_device, precondition.root_inode)
747
+ or (guard.parent_device, guard.parent_inode)
748
+ != (expected_parent[1], expected_parent[2])
749
+ or guard.target_existed
750
+ ):
751
+ raise WorkspacePathError(
752
+ "authorized output changed while creating its parent",
753
+ code="WORKSPACE_PATH_CHANGED",
754
+ reason="output_parent_changed",
755
+ )
756
+ return replace(
757
+ guard,
758
+ owned_parent_directories=tuple(created),
759
+ )
760
+ except BaseException:
761
+ self.cleanup_owned_parent_directories(created)
762
+ raise
763
+
764
+ @staticmethod
765
+ def cleanup_owned_parent_directories(
766
+ guard_or_directories: (
767
+ WorkspaceOutputGuard | Iterable[tuple[Path, int, int]]
768
+ ),
769
+ ) -> bool:
770
+ """Remove only exact, empty directories created for one publication."""
771
+
772
+ directories = (
773
+ guard_or_directories.owned_parent_directories
774
+ if isinstance(guard_or_directories, WorkspaceOutputGuard)
775
+ else tuple(guard_or_directories)
776
+ )
777
+ preserved = True
778
+ for path, device, inode in reversed(directories):
779
+ try:
780
+ observed = os.stat(path, follow_symlinks=False)
781
+ except FileNotFoundError:
782
+ continue
783
+ if (
784
+ not stat.S_ISDIR(observed.st_mode)
785
+ or (observed.st_dev, observed.st_ino) != (device, inode)
786
+ ):
787
+ preserved = False
788
+ continue
789
+ try:
790
+ os.rmdir(path)
791
+ except OSError:
792
+ preserved = False
793
+ return preserved
794
+
795
+ def _assert_output_guard(self, guard: WorkspaceOutputGuard) -> None:
796
+ """Fail closed when an output root/parent changed after authorization."""
797
+
798
+ try:
799
+ current = self.resolve(guard.path, must_exist=False)
800
+ parent = current.parent.resolve(strict=True)
801
+ root_stat = os.stat(guard.root, follow_symlinks=False)
802
+ parent_stat = os.stat(parent, follow_symlinks=False)
803
+ except WorkspacePathError:
804
+ raise
805
+ except OSError as exc:
806
+ raise WorkspacePathError(
807
+ "authorized output path changed before publication",
808
+ code="WORKSPACE_PATH_CHANGED",
809
+ reason="output_parent_changed",
810
+ ) from exc
811
+ if (
812
+ current != guard.path
813
+ or self._authorized_root(parent) != guard.root
814
+ or (root_stat.st_dev, root_stat.st_ino)
815
+ != (guard.root_device, guard.root_inode)
816
+ or (parent_stat.st_dev, parent_stat.st_ino)
817
+ != (guard.parent_device, guard.parent_inode)
818
+ or not stat.S_ISDIR(root_stat.st_mode)
819
+ or not stat.S_ISDIR(parent_stat.st_mode)
820
+ ):
821
+ raise WorkspacePathError(
822
+ "authorized output path changed before publication",
823
+ code="WORKSPACE_PATH_CHANGED",
824
+ reason="output_parent_changed",
825
+ )
826
+
827
+ def read_guarded_bytes(self, guard: WorkspaceOutputGuard) -> bytes:
828
+ """Read the exact regular file captured by *guard* without path chasing."""
829
+
830
+ self._assert_output_guard(guard)
831
+ if not guard.target_existed:
832
+ raise FileNotFoundError(guard.path)
833
+ if not _descriptor_cas_supported():
834
+ data = guard.path.read_bytes()
835
+ (
836
+ existed,
837
+ device,
838
+ inode,
839
+ digest,
840
+ mode,
841
+ ) = _snapshot_target(guard.path)
842
+ if (
843
+ not existed
844
+ or device != guard.target_device
845
+ or inode != guard.target_inode
846
+ or digest != guard.target_digest
847
+ or mode != guard.target_mode
848
+ or hashlib.sha256(data).hexdigest() != digest
849
+ ):
850
+ raise WorkspacePathError(
851
+ "authorized file changed while it was read",
852
+ code="WORKSPACE_PATH_CHANGED",
853
+ reason="output_target_changed",
854
+ )
855
+ self._assert_output_guard(guard)
856
+ return data
857
+
858
+ relative_parent = guard.path.parent.relative_to(guard.root)
859
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
860
+ root_fd = os.open(guard.root, directory_flags)
861
+ opened: list[int] = [root_fd]
862
+ parent_fd = root_fd
863
+ descriptor: int | None = None
864
+ try:
865
+ root_stat = os.fstat(root_fd)
866
+ if (root_stat.st_dev, root_stat.st_ino) != (
867
+ guard.root_device,
868
+ guard.root_inode,
869
+ ):
870
+ raise WorkspacePathError(
871
+ "authorized workspace root changed before read",
872
+ code="WORKSPACE_PATH_CHANGED",
873
+ reason="workspace_root_changed",
874
+ )
875
+ for component in relative_parent.parts:
876
+ next_fd = os.open(component, directory_flags, dir_fd=parent_fd)
877
+ opened.append(next_fd)
878
+ parent_fd = next_fd
879
+ parent_stat = os.fstat(parent_fd)
880
+ if (parent_stat.st_dev, parent_stat.st_ino) != (
881
+ guard.parent_device,
882
+ guard.parent_inode,
883
+ ):
884
+ raise WorkspacePathError(
885
+ "authorized output parent changed before read",
886
+ code="WORKSPACE_PATH_CHANGED",
887
+ reason="output_parent_changed",
888
+ )
889
+ self._assert_target_guard(guard, parent_fd)
890
+ descriptor = os.open(
891
+ guard.path.name,
892
+ os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0),
893
+ dir_fd=parent_fd,
894
+ )
895
+ before = os.fstat(descriptor)
896
+ chunks: list[bytes] = []
897
+ digest = hashlib.sha256()
898
+ while True:
899
+ chunk = os.read(descriptor, 1024 * 1024)
900
+ if not chunk:
901
+ break
902
+ chunks.append(chunk)
903
+ digest.update(chunk)
904
+ after = os.fstat(descriptor)
905
+ if (
906
+ (before.st_dev, before.st_ino)
907
+ != (guard.target_device, guard.target_inode)
908
+ or (
909
+ before.st_dev,
910
+ before.st_ino,
911
+ before.st_size,
912
+ before.st_mtime_ns,
913
+ )
914
+ != (
915
+ after.st_dev,
916
+ after.st_ino,
917
+ after.st_size,
918
+ after.st_mtime_ns,
919
+ )
920
+ or digest.hexdigest() != guard.target_digest
921
+ or stat.S_IMODE(after.st_mode) != guard.target_mode
922
+ ):
923
+ raise WorkspacePathError(
924
+ "authorized file changed while it was read",
925
+ code="WORKSPACE_PATH_CHANGED",
926
+ reason="output_target_changed",
927
+ )
928
+ self._assert_output_guard(guard)
929
+ return b"".join(chunks)
930
+ finally:
931
+ if descriptor is not None:
932
+ os.close(descriptor)
933
+ for opened_descriptor in reversed(opened):
934
+ os.close(opened_descriptor)
935
+
936
+ @staticmethod
937
+ def _assert_target_guard(
938
+ guard: WorkspaceOutputGuard,
939
+ parent_fd: int,
940
+ *,
941
+ name: str | None = None,
942
+ ) -> None:
943
+ """Compare a descriptor-relative target with the captured file bytes."""
944
+
945
+ target_name = name or guard.path.name
946
+ try:
947
+ device, inode, digest, mode = _relative_file_snapshot(
948
+ parent_fd,
949
+ target_name,
950
+ )
951
+ except FileNotFoundError:
952
+ if guard.target_existed:
953
+ raise WorkspacePathError(
954
+ "authorized output target disappeared before publication",
955
+ code="WORKSPACE_PATH_CHANGED",
956
+ reason="output_target_changed",
957
+ ) from None
958
+ return
959
+ except OSError as exc:
960
+ raise WorkspacePathError(
961
+ "authorized output target changed before publication",
962
+ code="WORKSPACE_PATH_CHANGED",
963
+ reason="output_target_changed",
964
+ ) from exc
965
+ if (
966
+ not guard.target_existed
967
+ or device != guard.target_device
968
+ or inode != guard.target_inode
969
+ or digest != guard.target_digest
970
+ or mode != guard.target_mode
971
+ ):
972
+ raise WorkspacePathError(
973
+ "authorized output target changed before publication",
974
+ code="WORKSPACE_PATH_CHANGED",
975
+ reason="output_target_changed",
976
+ )
977
+
978
+ def atomic_publish_bytes(
979
+ self,
980
+ guard: WorkspaceOutputGuard,
981
+ data: bytes,
982
+ *,
983
+ mode: int | None = None,
984
+ ) -> WorkspaceOutputGuard:
985
+ """Publish bytes and return the exact identity-bound candidate claim."""
986
+
987
+ self._assert_output_guard(guard)
988
+ if not _descriptor_cas_supported():
989
+ # Portable fallback retains both identity checks. POSIX hosts use
990
+ # the stronger root-anchored descriptor path below.
991
+ original_data = (
992
+ self.read_guarded_bytes(guard) if guard.target_existed else None
993
+ )
994
+ temporary = guard.path.parent / (
995
+ f".{guard.path.name}.{secrets.token_hex(8)}.tmp"
996
+ )
997
+ candidate_identity: tuple[int, int, str, int] | None = None
998
+ published = False
999
+
1000
+ def cleanup_owned_temporary(path: Path) -> None:
1001
+ try:
1002
+ path.unlink(missing_ok=True)
1003
+ except PermissionError:
1004
+ try:
1005
+ os.chmod(path, stat.S_IWRITE)
1006
+ except FileNotFoundError:
1007
+ return
1008
+ path.unlink(missing_ok=True)
1009
+
1010
+ def restore_owned_fallback_candidate() -> None:
1011
+ """Best-effort rollback only while our exact candidate is live."""
1012
+
1013
+ if candidate_identity is None:
1014
+ return
1015
+ try:
1016
+ observed = _snapshot_target(guard.path)
1017
+ except (OSError, WorkspacePathError):
1018
+ return
1019
+ if not observed[0] or observed[1:] != candidate_identity:
1020
+ return
1021
+ if not guard.target_existed:
1022
+ try:
1023
+ guard.path.unlink()
1024
+ except PermissionError:
1025
+ # Windows cannot unlink a read-only verification
1026
+ # snapshot until its read-only bit is cleared.
1027
+ os.chmod(guard.path, stat.S_IWRITE)
1028
+ writable = _snapshot_target(guard.path)
1029
+ if (
1030
+ not writable[0]
1031
+ or writable[1:4] != candidate_identity[:3]
1032
+ ):
1033
+ return
1034
+ guard.path.unlink()
1035
+ return
1036
+ if original_data is None: # pragma: no cover - guard invariant
1037
+ return
1038
+ restore = guard.path.parent / (
1039
+ f".{guard.path.name}.{secrets.token_hex(8)}.restore"
1040
+ )
1041
+ try:
1042
+ with restore.open("xb") as stream:
1043
+ stream.write(original_data)
1044
+ stream.flush()
1045
+ os.fsync(stream.fileno())
1046
+ os.chmod(
1047
+ restore,
1048
+ guard.target_mode
1049
+ if guard.target_mode is not None
1050
+ else 0o600,
1051
+ )
1052
+ # Recheck ownership immediately before the portable replace.
1053
+ current = _snapshot_target(guard.path)
1054
+ if not current[0] or current[1:] != candidate_identity:
1055
+ return
1056
+ os.replace(restore, guard.path)
1057
+ finally:
1058
+ cleanup_owned_temporary(restore)
1059
+
1060
+ try:
1061
+ with temporary.open("xb") as stream:
1062
+ stream.write(data)
1063
+ stream.flush()
1064
+ os.fsync(stream.fileno())
1065
+ desired_mode = (
1066
+ mode
1067
+ if mode is not None
1068
+ else guard.target_mode
1069
+ if guard.target_existed
1070
+ else 0o600
1071
+ )
1072
+ os.chmod(temporary, desired_mode)
1073
+ (
1074
+ candidate_existed,
1075
+ candidate_device,
1076
+ candidate_inode,
1077
+ candidate_digest,
1078
+ candidate_mode,
1079
+ ) = _snapshot_target(temporary)
1080
+ if (
1081
+ not candidate_existed
1082
+ or candidate_device is None
1083
+ or candidate_inode is None
1084
+ or candidate_digest != hashlib.sha256(data).hexdigest()
1085
+ or candidate_mode is None
1086
+ ):
1087
+ raise WorkspacePathError(
1088
+ "output candidate changed before publication",
1089
+ code="WORKSPACE_PATH_CHANGED",
1090
+ reason="output_candidate_changed",
1091
+ )
1092
+ # Use the platform-observed mode. Windows normalizes chmod to
1093
+ # its read-only bit instead of preserving arbitrary POSIX bits.
1094
+ candidate_identity = (
1095
+ candidate_device,
1096
+ candidate_inode,
1097
+ candidate_digest,
1098
+ candidate_mode,
1099
+ )
1100
+ self._assert_output_guard(guard)
1101
+ (
1102
+ target_existed,
1103
+ target_device,
1104
+ target_inode,
1105
+ target_digest,
1106
+ target_mode,
1107
+ ) = _snapshot_target(guard.path)
1108
+ if (
1109
+ target_existed != guard.target_existed
1110
+ or target_device != guard.target_device
1111
+ or target_inode != guard.target_inode
1112
+ or target_digest != guard.target_digest
1113
+ or target_mode != guard.target_mode
1114
+ ):
1115
+ raise WorkspacePathError(
1116
+ "authorized output target changed before publication",
1117
+ code="WORKSPACE_PATH_CHANGED",
1118
+ reason="output_target_changed",
1119
+ )
1120
+ os.replace(temporary, guard.path)
1121
+ published = True
1122
+ finally:
1123
+ cleanup_owned_temporary(temporary)
1124
+ try:
1125
+ self._assert_output_guard(guard)
1126
+ observed = _snapshot_target(guard.path)
1127
+ if (
1128
+ candidate_identity is None
1129
+ or not observed[0]
1130
+ or observed[1:] != candidate_identity
1131
+ ):
1132
+ raise WorkspacePathError(
1133
+ "published output differs from the verified candidate",
1134
+ code="WORKSPACE_PATH_CHANGED",
1135
+ reason="output_target_changed",
1136
+ )
1137
+ except BaseException:
1138
+ if published:
1139
+ restore_owned_fallback_candidate()
1140
+ raise
1141
+ try:
1142
+ claim = self.capture_output(guard.path, create_parents=False)
1143
+ if (
1144
+ candidate_identity is None
1145
+ or claim.path != guard.path
1146
+ or claim.root != guard.root
1147
+ or (claim.root_device, claim.root_inode)
1148
+ != (guard.root_device, guard.root_inode)
1149
+ or (claim.parent_device, claim.parent_inode)
1150
+ != (guard.parent_device, guard.parent_inode)
1151
+ or not claim.target_existed
1152
+ or (
1153
+ claim.target_device,
1154
+ claim.target_inode,
1155
+ claim.target_digest,
1156
+ claim.target_mode,
1157
+ )
1158
+ != candidate_identity
1159
+ ):
1160
+ raise WorkspacePathError(
1161
+ "published output changed before ownership was claimed",
1162
+ code="WORKSPACE_PATH_CHANGED",
1163
+ reason="output_target_changed",
1164
+ )
1165
+ return replace(
1166
+ claim,
1167
+ owned_parent_directories=guard.owned_parent_directories,
1168
+ )
1169
+ except BaseException:
1170
+ if published:
1171
+ restore_owned_fallback_candidate()
1172
+ raise
1173
+
1174
+ relative_parent = guard.path.parent.relative_to(guard.root)
1175
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
1176
+ root_fd = os.open(guard.root, directory_flags)
1177
+ opened: list[int] = [root_fd]
1178
+ temporary_name = f".{guard.path.name}.{secrets.token_hex(8)}.tmp"
1179
+ parent_fd = root_fd
1180
+ temp_created = False
1181
+ snapshot_file = None
1182
+ try:
1183
+ root_stat = os.fstat(root_fd)
1184
+ if (root_stat.st_dev, root_stat.st_ino) != (
1185
+ guard.root_device,
1186
+ guard.root_inode,
1187
+ ):
1188
+ raise WorkspacePathError(
1189
+ "authorized workspace root changed before publication",
1190
+ code="WORKSPACE_PATH_CHANGED",
1191
+ reason="workspace_root_changed",
1192
+ )
1193
+ for component in relative_parent.parts:
1194
+ next_fd = os.open(
1195
+ component,
1196
+ directory_flags,
1197
+ dir_fd=parent_fd,
1198
+ )
1199
+ opened.append(next_fd)
1200
+ parent_fd = next_fd
1201
+ parent_stat = os.fstat(parent_fd)
1202
+ if (parent_stat.st_dev, parent_stat.st_ino) != (
1203
+ guard.parent_device,
1204
+ guard.parent_inode,
1205
+ ):
1206
+ raise WorkspacePathError(
1207
+ "authorized output parent changed before publication",
1208
+ code="WORKSPACE_PATH_CHANGED",
1209
+ reason="output_parent_changed",
1210
+ )
1211
+ self._assert_target_guard(guard, parent_fd)
1212
+ if guard.target_existed:
1213
+ snapshot_file = tempfile.TemporaryFile()
1214
+ source_fd = os.open(
1215
+ guard.path.name,
1216
+ os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0),
1217
+ dir_fd=parent_fd,
1218
+ )
1219
+ try:
1220
+ before = os.fstat(source_fd)
1221
+ snapshot_digest = _digest_descriptor(
1222
+ source_fd,
1223
+ copy_to=snapshot_file.fileno(),
1224
+ )
1225
+ after = os.fstat(source_fd)
1226
+ finally:
1227
+ os.close(source_fd)
1228
+ if (
1229
+ not stat.S_ISREG(after.st_mode)
1230
+ or (
1231
+ before.st_dev,
1232
+ before.st_ino,
1233
+ before.st_size,
1234
+ before.st_mtime_ns,
1235
+ )
1236
+ != (
1237
+ after.st_dev,
1238
+ after.st_ino,
1239
+ after.st_size,
1240
+ after.st_mtime_ns,
1241
+ )
1242
+ or snapshot_digest != guard.target_digest
1243
+ or stat.S_IMODE(after.st_mode) != guard.target_mode
1244
+ ):
1245
+ raise WorkspacePathError(
1246
+ "authorized output target changed while snapshotting",
1247
+ code="WORKSPACE_PATH_CHANGED",
1248
+ reason="output_target_changed",
1249
+ )
1250
+ temp_fd = os.open(
1251
+ temporary_name,
1252
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1253
+ 0o600,
1254
+ dir_fd=parent_fd,
1255
+ )
1256
+ temp_created = True
1257
+ try:
1258
+ desired_mode = (
1259
+ mode
1260
+ if mode is not None
1261
+ else guard.target_mode
1262
+ if guard.target_existed
1263
+ else 0o600
1264
+ )
1265
+ os.fchmod(temp_fd, desired_mode)
1266
+ view = memoryview(data)
1267
+ while view:
1268
+ written = os.write(temp_fd, view)
1269
+ view = view[written:]
1270
+ os.fsync(temp_fd)
1271
+ finally:
1272
+ os.close(temp_fd)
1273
+ candidate_device, candidate_inode, candidate_digest, candidate_mode = (
1274
+ _relative_file_snapshot(parent_fd, temporary_name)
1275
+ )
1276
+ if (
1277
+ candidate_digest != hashlib.sha256(data).hexdigest()
1278
+ or candidate_mode != desired_mode
1279
+ ):
1280
+ raise WorkspacePathError(
1281
+ "output candidate changed before publication",
1282
+ code="WORKSPACE_PATH_CHANGED",
1283
+ reason="output_candidate_changed",
1284
+ )
1285
+
1286
+ def candidate_matches(name: str) -> bool:
1287
+ try:
1288
+ observed = _relative_file_snapshot(parent_fd, name)
1289
+ except (OSError, WorkspacePathError):
1290
+ return False
1291
+ return observed == (
1292
+ candidate_device,
1293
+ candidate_inode,
1294
+ candidate_digest,
1295
+ candidate_mode,
1296
+ )
1297
+
1298
+ def restore_original_if_candidate() -> bool:
1299
+ if snapshot_file is None or not candidate_matches(guard.path.name):
1300
+ return False
1301
+ restore_name = (
1302
+ f".{guard.path.name}.{secrets.token_hex(8)}.restore"
1303
+ )
1304
+ restore_created = False
1305
+ restore_fd = os.open(
1306
+ restore_name,
1307
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1308
+ 0o600,
1309
+ dir_fd=parent_fd,
1310
+ )
1311
+ restore_created = True
1312
+ try:
1313
+ os.fchmod(
1314
+ restore_fd,
1315
+ guard.target_mode
1316
+ if guard.target_mode is not None
1317
+ else 0o600,
1318
+ )
1319
+ os.lseek(snapshot_file.fileno(), 0, os.SEEK_SET)
1320
+ while True:
1321
+ chunk = os.read(snapshot_file.fileno(), 1024 * 1024)
1322
+ if not chunk:
1323
+ break
1324
+ view = memoryview(chunk)
1325
+ while view:
1326
+ written = os.write(restore_fd, view)
1327
+ view = view[written:]
1328
+ os.fsync(restore_fd)
1329
+ finally:
1330
+ os.close(restore_fd)
1331
+ try:
1332
+ _exchange_entries(parent_fd, restore_name, guard.path.name)
1333
+ if not candidate_matches(restore_name):
1334
+ _exchange_entries(
1335
+ parent_fd,
1336
+ restore_name,
1337
+ guard.path.name,
1338
+ )
1339
+ return False
1340
+ os.unlink(restore_name, dir_fd=parent_fd)
1341
+ restore_created = False
1342
+ os.fsync(parent_fd)
1343
+ return True
1344
+ finally:
1345
+ if restore_created:
1346
+ try:
1347
+ os.unlink(restore_name, dir_fd=parent_fd)
1348
+ except OSError:
1349
+ pass
1350
+
1351
+ self._assert_output_guard(guard)
1352
+ self._assert_target_guard(guard, parent_fd)
1353
+ published = False
1354
+ try:
1355
+ if guard.target_existed:
1356
+ try:
1357
+ _exchange_entries(parent_fd, temporary_name, guard.path.name)
1358
+ except OSError as exc:
1359
+ raise WorkspacePathError(
1360
+ "authorized output target changed before publication",
1361
+ code="WORKSPACE_PATH_CHANGED",
1362
+ reason="output_target_changed",
1363
+ ) from exc
1364
+ published = True
1365
+ try:
1366
+ self._assert_target_guard(
1367
+ guard,
1368
+ parent_fd,
1369
+ name=temporary_name,
1370
+ )
1371
+ except BaseException:
1372
+ # The displaced entry is not the state we captured.
1373
+ # Exchange it back so an external writer is preserved.
1374
+ _exchange_entries(
1375
+ parent_fd,
1376
+ temporary_name,
1377
+ guard.path.name,
1378
+ )
1379
+ published = False
1380
+ raise
1381
+ if not candidate_matches(guard.path.name):
1382
+ raise WorkspacePathError(
1383
+ "published output differs from the verified candidate",
1384
+ code="WORKSPACE_PATH_CHANGED",
1385
+ reason="output_target_changed",
1386
+ )
1387
+ self._assert_output_guard(guard)
1388
+ os.unlink(temporary_name, dir_fd=parent_fd)
1389
+ temp_created = False
1390
+ else:
1391
+ try:
1392
+ os.link(
1393
+ temporary_name,
1394
+ guard.path.name,
1395
+ src_dir_fd=parent_fd,
1396
+ dst_dir_fd=parent_fd,
1397
+ follow_symlinks=False,
1398
+ )
1399
+ except FileExistsError as exc:
1400
+ raise WorkspacePathError(
1401
+ "authorized output target appeared before publication",
1402
+ code="WORKSPACE_PATH_CHANGED",
1403
+ reason="output_target_changed",
1404
+ ) from exc
1405
+ published = True
1406
+ if not candidate_matches(guard.path.name):
1407
+ raise WorkspacePathError(
1408
+ "published output differs from the verified candidate",
1409
+ code="WORKSPACE_PATH_CHANGED",
1410
+ reason="output_target_changed",
1411
+ )
1412
+ self._assert_output_guard(guard)
1413
+ os.unlink(temporary_name, dir_fd=parent_fd)
1414
+ temp_created = False
1415
+ os.fsync(parent_fd)
1416
+ if not candidate_matches(guard.path.name):
1417
+ raise WorkspacePathError(
1418
+ "published output changed before completion",
1419
+ code="WORKSPACE_PATH_CHANGED",
1420
+ reason="output_target_changed",
1421
+ )
1422
+ self._assert_output_guard(guard)
1423
+ except BaseException:
1424
+ if published and guard.target_existed:
1425
+ restore_original_if_candidate()
1426
+ elif published:
1427
+ if candidate_matches(guard.path.name):
1428
+ os.unlink(guard.path.name, dir_fd=parent_fd)
1429
+ os.fsync(parent_fd)
1430
+ raise
1431
+ finally:
1432
+ if temp_created:
1433
+ try:
1434
+ os.unlink(temporary_name, dir_fd=parent_fd)
1435
+ except OSError:
1436
+ pass
1437
+ if snapshot_file is not None:
1438
+ snapshot_file.close()
1439
+ for descriptor in reversed(opened):
1440
+ os.close(descriptor)
1441
+ return WorkspaceOutputGuard(
1442
+ path=guard.path,
1443
+ root=guard.root,
1444
+ root_device=guard.root_device,
1445
+ root_inode=guard.root_inode,
1446
+ parent_device=guard.parent_device,
1447
+ parent_inode=guard.parent_inode,
1448
+ target_existed=True,
1449
+ target_device=candidate_device,
1450
+ target_inode=candidate_inode,
1451
+ target_digest=candidate_digest,
1452
+ target_mode=candidate_mode,
1453
+ owned_parent_directories=guard.owned_parent_directories,
1454
+ )
1455
+
1456
+ def atomic_write_bytes(
1457
+ self,
1458
+ guard: WorkspaceOutputGuard,
1459
+ data: bytes,
1460
+ *,
1461
+ mode: int | None = None,
1462
+ ) -> Path:
1463
+ """Publish bytes through a guarded writer and return the output path."""
1464
+
1465
+ return self.atomic_publish_bytes(guard, data, mode=mode).path
1466
+
1467
+ def remove_output(self, guard: WorkspaceOutputGuard) -> None:
1468
+ """Remove only the exact regular file captured by *guard*."""
1469
+
1470
+ self._assert_output_guard(guard)
1471
+ if not guard.target_existed:
1472
+ return
1473
+ if not _descriptor_cas_supported():
1474
+ (
1475
+ existed,
1476
+ device,
1477
+ inode,
1478
+ digest,
1479
+ mode,
1480
+ ) = _snapshot_target(guard.path)
1481
+ if (
1482
+ not existed
1483
+ or device != guard.target_device
1484
+ or inode != guard.target_inode
1485
+ or digest != guard.target_digest
1486
+ or mode != guard.target_mode
1487
+ ):
1488
+ raise WorkspacePathError(
1489
+ "authorized output target changed before removal",
1490
+ code="WORKSPACE_PATH_CHANGED",
1491
+ reason="output_target_changed",
1492
+ )
1493
+ try:
1494
+ guard.path.unlink()
1495
+ except PermissionError:
1496
+ os.chmod(guard.path, stat.S_IWRITE)
1497
+ writable = _snapshot_target(guard.path)
1498
+ if (
1499
+ not writable[0]
1500
+ or writable[1] != guard.target_device
1501
+ or writable[2] != guard.target_inode
1502
+ or writable[3] != guard.target_digest
1503
+ ):
1504
+ raise WorkspacePathError(
1505
+ "authorized output target changed before removal",
1506
+ code="WORKSPACE_PATH_CHANGED",
1507
+ reason="output_target_changed",
1508
+ )
1509
+ guard.path.unlink()
1510
+ self._assert_output_guard(guard)
1511
+ return
1512
+
1513
+ relative_parent = guard.path.parent.relative_to(guard.root)
1514
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
1515
+ root_fd = os.open(guard.root, directory_flags)
1516
+ opened: list[int] = [root_fd]
1517
+ parent_fd = root_fd
1518
+ sentinel_name = f".{guard.path.name}.{secrets.token_hex(8)}.remove"
1519
+ sentinel_created = False
1520
+ exchanged = False
1521
+ sentinel_at_target = False
1522
+ try:
1523
+ root_stat = os.fstat(root_fd)
1524
+ if (root_stat.st_dev, root_stat.st_ino) != (
1525
+ guard.root_device,
1526
+ guard.root_inode,
1527
+ ):
1528
+ raise WorkspacePathError(
1529
+ "authorized workspace root changed before removal",
1530
+ code="WORKSPACE_PATH_CHANGED",
1531
+ reason="workspace_root_changed",
1532
+ )
1533
+ for component in relative_parent.parts:
1534
+ next_fd = os.open(component, directory_flags, dir_fd=parent_fd)
1535
+ opened.append(next_fd)
1536
+ parent_fd = next_fd
1537
+ parent_stat = os.fstat(parent_fd)
1538
+ if (parent_stat.st_dev, parent_stat.st_ino) != (
1539
+ guard.parent_device,
1540
+ guard.parent_inode,
1541
+ ):
1542
+ raise WorkspacePathError(
1543
+ "authorized output parent changed before removal",
1544
+ code="WORKSPACE_PATH_CHANGED",
1545
+ reason="output_parent_changed",
1546
+ )
1547
+ self._assert_target_guard(guard, parent_fd)
1548
+ os.mkdir(sentinel_name, mode=0o700, dir_fd=parent_fd)
1549
+ sentinel_created = True
1550
+ sentinel_stat = os.stat(
1551
+ sentinel_name,
1552
+ dir_fd=parent_fd,
1553
+ follow_symlinks=False,
1554
+ )
1555
+ sentinel_identity = (sentinel_stat.st_dev, sentinel_stat.st_ino)
1556
+ _exchange_entries(parent_fd, sentinel_name, guard.path.name)
1557
+ exchanged = True
1558
+ sentinel_at_target = True
1559
+ try:
1560
+ self._assert_target_guard(guard, parent_fd, name=sentinel_name)
1561
+ except BaseException:
1562
+ _exchange_entries(parent_fd, sentinel_name, guard.path.name)
1563
+ exchanged = False
1564
+ sentinel_at_target = False
1565
+ raise
1566
+ current_sentinel = os.stat(
1567
+ guard.path.name,
1568
+ dir_fd=parent_fd,
1569
+ follow_symlinks=False,
1570
+ )
1571
+ if (
1572
+ not stat.S_ISDIR(current_sentinel.st_mode)
1573
+ or (current_sentinel.st_dev, current_sentinel.st_ino)
1574
+ != sentinel_identity
1575
+ ):
1576
+ raise WorkspacePathError(
1577
+ "authorized output target changed during removal",
1578
+ code="WORKSPACE_PATH_CHANGED",
1579
+ reason="output_target_changed",
1580
+ )
1581
+ try:
1582
+ os.rmdir(guard.path.name, dir_fd=parent_fd)
1583
+ except OSError as exc:
1584
+ raise WorkspacePathError(
1585
+ "authorized output target changed during removal",
1586
+ code="WORKSPACE_PATH_CHANGED",
1587
+ reason="output_target_changed",
1588
+ ) from exc
1589
+ sentinel_at_target = False
1590
+ os.fsync(parent_fd)
1591
+ try:
1592
+ os.stat(
1593
+ guard.path.name,
1594
+ dir_fd=parent_fd,
1595
+ follow_symlinks=False,
1596
+ )
1597
+ except FileNotFoundError:
1598
+ pass
1599
+ else:
1600
+ raise WorkspacePathError(
1601
+ "authorized output target changed during removal",
1602
+ code="WORKSPACE_PATH_CHANGED",
1603
+ reason="output_target_changed",
1604
+ )
1605
+ self._assert_output_guard(guard)
1606
+ self._assert_target_guard(guard, parent_fd, name=sentinel_name)
1607
+ os.unlink(sentinel_name, dir_fd=parent_fd)
1608
+ sentinel_created = False
1609
+ exchanged = False
1610
+ except BaseException:
1611
+ if exchanged and sentinel_at_target:
1612
+ target_is_sentinel = False
1613
+ try:
1614
+ current_sentinel = os.stat(
1615
+ guard.path.name,
1616
+ dir_fd=parent_fd,
1617
+ follow_symlinks=False,
1618
+ )
1619
+ target_is_sentinel = bool(
1620
+ stat.S_ISDIR(current_sentinel.st_mode)
1621
+ and (current_sentinel.st_dev, current_sentinel.st_ino)
1622
+ == sentinel_identity
1623
+ )
1624
+ if target_is_sentinel:
1625
+ _exchange_entries(
1626
+ parent_fd,
1627
+ sentinel_name,
1628
+ guard.path.name,
1629
+ )
1630
+ exchanged = False
1631
+ sentinel_at_target = False
1632
+ except (FileNotFoundError, OSError, WorkspacePathError):
1633
+ pass
1634
+ if exchanged and not target_is_sentinel:
1635
+ try:
1636
+ self._assert_target_guard(
1637
+ guard,
1638
+ parent_fd,
1639
+ name=sentinel_name,
1640
+ )
1641
+ os.unlink(sentinel_name, dir_fd=parent_fd)
1642
+ sentinel_created = False
1643
+ exchanged = False
1644
+ sentinel_at_target = False
1645
+ except (FileNotFoundError, OSError, WorkspacePathError):
1646
+ pass
1647
+ elif exchanged:
1648
+ try:
1649
+ self._assert_target_guard(
1650
+ guard,
1651
+ parent_fd,
1652
+ name=sentinel_name,
1653
+ )
1654
+ try:
1655
+ os.link(
1656
+ sentinel_name,
1657
+ guard.path.name,
1658
+ src_dir_fd=parent_fd,
1659
+ dst_dir_fd=parent_fd,
1660
+ follow_symlinks=False,
1661
+ )
1662
+ except FileExistsError:
1663
+ # Preserve the external winner but remove only our exact,
1664
+ # randomly named displaced candidate.
1665
+ pass
1666
+ os.unlink(sentinel_name, dir_fd=parent_fd)
1667
+ sentinel_created = False
1668
+ exchanged = False
1669
+ except (FileNotFoundError, OSError, WorkspacePathError):
1670
+ pass
1671
+ raise
1672
+ finally:
1673
+ if sentinel_created and not exchanged:
1674
+ try:
1675
+ os.rmdir(sentinel_name, dir_fd=parent_fd)
1676
+ except OSError:
1677
+ pass
1678
+ for descriptor in reversed(opened):
1679
+ os.close(descriptor)
1680
+
1681
+ def display_path(self, path: Path) -> str:
1682
+ resolved = path.resolve(strict=False)
1683
+ if resolved == self.primary_root or self.primary_root in resolved.parents:
1684
+ return str(resolved.relative_to(self.primary_root))
1685
+ return str(resolved)
1686
+
1687
+ def describe(self) -> dict[str, object]:
1688
+ return {
1689
+ "source": self.source,
1690
+ "roots": [str(root) for root in self.roots],
1691
+ "rootCount": len(self.roots),
1692
+ "relativePathRoot": str(self.primary_root),
1693
+ "failClosed": True,
1694
+ }