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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
}
|