graphite-code 0.3.0__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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Graphite — local-first, zero-LLM knowledge graph extraction.
|
|
2
|
+
|
|
3
|
+
**The supported surface is the CLI, the MCP server, and the published JSON
|
|
4
|
+
schemas — not this package's Python API.**
|
|
5
|
+
|
|
6
|
+
That is a deliberate boundary, not an omission. `docs/agent-integration.md` is
|
|
7
|
+
the consumer contract and every example in it is `graphite <command> --json`;
|
|
8
|
+
the payload shapes are pinned in `docs/schemas/` and held in lockstep with live
|
|
9
|
+
output by tests. Nothing documented asks a consumer to import anything from
|
|
10
|
+
here, so `__all__` stays at the one name that is genuinely public.
|
|
11
|
+
|
|
12
|
+
Importing internals (`graphite.query`, `graphite.routing.*`, …) is therefore
|
|
13
|
+
supported by nobody: those move without notice, and an editable install means
|
|
14
|
+
they move the moment a file is saved.
|
|
15
|
+
|
|
16
|
+
The other half of the surface is a set of MODULE PATHS rather than objects.
|
|
17
|
+
`graphite.mcp` is written into every onboarded repo's `.mcp.json` and
|
|
18
|
+
`graphite.hook_entry` into its `.githooks/` trampolines, so renaming either
|
|
19
|
+
breaks consumers that will never see the change — and because those artifacts
|
|
20
|
+
are already on disk, fixing the generator afterwards does not repair them. They
|
|
21
|
+
are pinned by `tests/test_public_surface.py` for that reason.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
#: The only object this package promises. See the module docstring for why.
|
|
26
|
+
__all__ = ["__version__"]
|
|
27
|
+
|
|
28
|
+
#: THE version. `pyproject.toml` reads this file rather than the other way
|
|
29
|
+
#: round (`[tool.hatch.version]`), so there is exactly one place to set it.
|
|
30
|
+
#:
|
|
31
|
+
#: Through 0.2.1 this was doubly load-bearing: every consumer on this machine
|
|
32
|
+
#: ran graphite from one shared editable install, so the source tree WAS the
|
|
33
|
+
#: deployment and a saved file was live everywhere with no build and no
|
|
34
|
+
#: boundary. 0.3.0 is the first version consumers install as a built wheel, so
|
|
35
|
+
#: this string and the installed metadata are now set together by a release
|
|
36
|
+
#: rather than drifting apart between them.
|
|
37
|
+
#:
|
|
38
|
+
#: Coarse and hand-maintained by construction. It is a release label, not
|
|
39
|
+
#: evidence that a given fix is present -- to answer that, survey for the marker
|
|
40
|
+
#: the fix introduced, or compare `graphite --version` fingerprints.
|
|
41
|
+
__version__ = "0.3.0"
|
graphite/__main__.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Handle-bound deletion worker for TypeScript activation temporary homes.
|
|
2
|
+
|
|
3
|
+
This module is executed as an isolated Python script by ``typescript_activation``.
|
|
4
|
+
It intentionally has no Graphite imports so ``python -I`` can run the packaged
|
|
5
|
+
file without trusting ``PYTHONPATH`` or the selected repository.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ctypes
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from itertools import islice
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
EXIT_UNSAFE = 73
|
|
20
|
+
EXIT_FAILED = 74
|
|
21
|
+
EXIT_POSIX_ROOT_RETAINED = 75
|
|
22
|
+
_ENUMERATION_BATCH_SIZE = 64
|
|
23
|
+
_WINDOWS_DIRECTORY_BUFFER_BYTES = 64 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _same_posix_identity(details: os.stat_result, expected: tuple[int, int]) -> bool:
|
|
27
|
+
return (details.st_dev, details.st_ino) == expected
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _posix_stat_at(parent_fd: int, name: str) -> os.stat_result:
|
|
31
|
+
return os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _posix_open_directory(parent_fd: int, name: str) -> int:
|
|
35
|
+
flags = (
|
|
36
|
+
os.O_RDONLY
|
|
37
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
38
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
39
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
40
|
+
)
|
|
41
|
+
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
|
42
|
+
os.set_inheritable(descriptor, False)
|
|
43
|
+
return descriptor
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _posix_open_nondirectory(parent_fd: int, name: str) -> int:
|
|
47
|
+
flags = (
|
|
48
|
+
os.O_RDONLY
|
|
49
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
50
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
51
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
52
|
+
)
|
|
53
|
+
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
|
54
|
+
os.set_inheritable(descriptor, False)
|
|
55
|
+
return descriptor
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _valid_entry_name(name: str) -> bool:
|
|
59
|
+
return bool(name) and name not in {".", ".."} and "/" not in name and "\0" not in name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _posix_entry_batch(descriptor: int) -> list[tuple[str, os.stat_result]]:
|
|
63
|
+
with os.scandir(descriptor) as entries:
|
|
64
|
+
return [
|
|
65
|
+
(entry.name, entry.stat(follow_symlinks=False))
|
|
66
|
+
for entry in islice(entries, _ENUMERATION_BATCH_SIZE)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _delete_posix_entry(
|
|
71
|
+
descriptor: int, name: str, observed: os.stat_result
|
|
72
|
+
) -> None:
|
|
73
|
+
if not _valid_entry_name(name):
|
|
74
|
+
raise OSError("unsafe directory entry")
|
|
75
|
+
expected = (observed.st_dev, observed.st_ino)
|
|
76
|
+
if stat.S_ISDIR(observed.st_mode) and not stat.S_ISLNK(observed.st_mode):
|
|
77
|
+
child_fd = _posix_open_directory(descriptor, name)
|
|
78
|
+
try:
|
|
79
|
+
if not _same_posix_identity(os.fstat(child_fd), expected):
|
|
80
|
+
raise OSError("directory identity changed")
|
|
81
|
+
_delete_posix_directory(child_fd)
|
|
82
|
+
current = _posix_stat_at(descriptor, name)
|
|
83
|
+
if not _same_posix_identity(current, expected) or not stat.S_ISDIR(
|
|
84
|
+
current.st_mode
|
|
85
|
+
):
|
|
86
|
+
raise OSError("directory binding changed")
|
|
87
|
+
os.rmdir(name, dir_fd=descriptor)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(child_fd)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if stat.S_ISLNK(observed.st_mode):
|
|
93
|
+
current = _posix_stat_at(descriptor, name)
|
|
94
|
+
if not _same_posix_identity(current, expected) or not stat.S_ISLNK(
|
|
95
|
+
current.st_mode
|
|
96
|
+
):
|
|
97
|
+
raise OSError("symlink binding changed")
|
|
98
|
+
os.unlink(name, dir_fd=descriptor)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
child_fd = _posix_open_nondirectory(descriptor, name)
|
|
102
|
+
try:
|
|
103
|
+
opened = os.fstat(child_fd)
|
|
104
|
+
if not _same_posix_identity(opened, expected) or stat.S_ISDIR(opened.st_mode):
|
|
105
|
+
raise OSError("file identity changed")
|
|
106
|
+
current = _posix_stat_at(descriptor, name)
|
|
107
|
+
if not _same_posix_identity(current, expected) or stat.S_ISDIR(current.st_mode):
|
|
108
|
+
raise OSError("file binding changed")
|
|
109
|
+
os.unlink(name, dir_fd=descriptor)
|
|
110
|
+
finally:
|
|
111
|
+
os.close(child_fd)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _delete_posix_directory(descriptor: int) -> None:
|
|
115
|
+
while batch := _posix_entry_batch(descriptor):
|
|
116
|
+
for name, observed in batch:
|
|
117
|
+
_delete_posix_entry(descriptor, name, observed)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _delete_posix(path: Path, expected: tuple[int, int]) -> None:
|
|
121
|
+
"""Empty the identity-bound lease while deliberately retaining its root.
|
|
122
|
+
|
|
123
|
+
POSIX ``unlinkat``/``rmdir`` accepts a name relative to a directory file
|
|
124
|
+
descriptor; it cannot remove the directory referenced by an already-open
|
|
125
|
+
descriptor. A final stat-then-rmdir would therefore reopen the root-name
|
|
126
|
+
replacement race. The root was created by Graphite and its contents are
|
|
127
|
+
disposable after the package manager runs, so contents are removed only
|
|
128
|
+
through the held, no-follow descriptor and the empty root is left for OS
|
|
129
|
+
temporary-directory reclamation.
|
|
130
|
+
|
|
131
|
+
A hostile process running as the same UID can still race individual names
|
|
132
|
+
inside that disposable root between validation and unlink. It can cause
|
|
133
|
+
cleanup failure or replace disposable contents, but descriptor-relative
|
|
134
|
+
traversal cannot escape the held root and this worker never removes a
|
|
135
|
+
replacement of the root itself or any ancestor.
|
|
136
|
+
"""
|
|
137
|
+
parent = path.parent
|
|
138
|
+
name = path.name
|
|
139
|
+
if not path.is_absolute() or not _valid_entry_name(name):
|
|
140
|
+
raise OSError("cleanup path invalid")
|
|
141
|
+
parent_flags = (
|
|
142
|
+
os.O_RDONLY
|
|
143
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
144
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
145
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
146
|
+
)
|
|
147
|
+
parent_fd = os.open(parent, parent_flags)
|
|
148
|
+
os.set_inheritable(parent_fd, False)
|
|
149
|
+
root_fd = -1
|
|
150
|
+
try:
|
|
151
|
+
root_fd = _posix_open_directory(parent_fd, name)
|
|
152
|
+
details = os.fstat(root_fd)
|
|
153
|
+
if not _same_posix_identity(details, expected) or not stat.S_ISDIR(details.st_mode):
|
|
154
|
+
raise OSError("cleanup lease changed")
|
|
155
|
+
_delete_posix_directory(root_fd)
|
|
156
|
+
finally:
|
|
157
|
+
if root_fd >= 0:
|
|
158
|
+
os.close(root_fd)
|
|
159
|
+
os.close(parent_fd)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class _WindowsEntry:
|
|
164
|
+
name: str
|
|
165
|
+
file_id: int
|
|
166
|
+
attributes: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _WindowsBackend(Protocol):
|
|
170
|
+
directory_attribute: int
|
|
171
|
+
reparse_attribute: int
|
|
172
|
+
|
|
173
|
+
def open(self, path: Path, *, directory: bool) -> int: ...
|
|
174
|
+
|
|
175
|
+
def information(self, handle: int) -> tuple[int, int, int]: ...
|
|
176
|
+
|
|
177
|
+
def entry_batch(self, handle: int) -> list[_WindowsEntry]: ...
|
|
178
|
+
|
|
179
|
+
def mark_delete(self, handle: int) -> None: ...
|
|
180
|
+
|
|
181
|
+
def close(self, handle: int) -> None: ...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _FileIdInformation(ctypes.Structure):
|
|
185
|
+
_fields_ = (
|
|
186
|
+
("volume_serial", ctypes.c_uint64),
|
|
187
|
+
("file_id", ctypes.c_ubyte * 16),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _FileAttributeTagInformation(ctypes.Structure):
|
|
192
|
+
_fields_ = (
|
|
193
|
+
("file_attributes", ctypes.c_uint32),
|
|
194
|
+
("reparse_tag", ctypes.c_uint32),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _FileIdExtdDirectoryInformation(ctypes.Structure):
|
|
199
|
+
_fields_ = (
|
|
200
|
+
("next_entry_offset", ctypes.c_uint32),
|
|
201
|
+
("file_index", ctypes.c_uint32),
|
|
202
|
+
("creation_time", ctypes.c_int64),
|
|
203
|
+
("last_access_time", ctypes.c_int64),
|
|
204
|
+
("last_write_time", ctypes.c_int64),
|
|
205
|
+
("change_time", ctypes.c_int64),
|
|
206
|
+
("end_of_file", ctypes.c_int64),
|
|
207
|
+
("allocation_size", ctypes.c_int64),
|
|
208
|
+
("file_attributes", ctypes.c_uint32),
|
|
209
|
+
("file_name_length", ctypes.c_uint32),
|
|
210
|
+
("ea_size", ctypes.c_uint32),
|
|
211
|
+
("reparse_tag", ctypes.c_uint32),
|
|
212
|
+
("file_id", ctypes.c_ubyte * 16),
|
|
213
|
+
("file_name", ctypes.c_uint16 * 1),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _FileDispositionInformation(ctypes.Structure):
|
|
218
|
+
_fields_ = (("delete_file", ctypes.c_int),)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _FileDispositionInformationEx(ctypes.Structure):
|
|
222
|
+
_fields_ = (("flags", ctypes.c_uint32),)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _FileBasicInformation(ctypes.Structure):
|
|
226
|
+
_fields_ = (
|
|
227
|
+
("creation_time", ctypes.c_int64),
|
|
228
|
+
("last_access_time", ctypes.c_int64),
|
|
229
|
+
("last_write_time", ctypes.c_int64),
|
|
230
|
+
("change_time", ctypes.c_int64),
|
|
231
|
+
("file_attributes", ctypes.c_uint32),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _NativeWindowsBackend:
|
|
236
|
+
directory_attribute = 0x10
|
|
237
|
+
reparse_attribute = 0x400
|
|
238
|
+
_readonly_attribute = 0x1
|
|
239
|
+
_invalid_handle = ctypes.c_void_p(-1).value
|
|
240
|
+
_error_no_more_files = 18
|
|
241
|
+
_fallback_errors = {1, 50, 87}
|
|
242
|
+
|
|
243
|
+
def __init__(self) -> None:
|
|
244
|
+
from ctypes import wintypes
|
|
245
|
+
|
|
246
|
+
self._wintypes = wintypes
|
|
247
|
+
self._api = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
248
|
+
self._api.CreateFileW.argtypes = (
|
|
249
|
+
wintypes.LPCWSTR,
|
|
250
|
+
wintypes.DWORD,
|
|
251
|
+
wintypes.DWORD,
|
|
252
|
+
wintypes.LPVOID,
|
|
253
|
+
wintypes.DWORD,
|
|
254
|
+
wintypes.DWORD,
|
|
255
|
+
wintypes.HANDLE,
|
|
256
|
+
)
|
|
257
|
+
self._api.CreateFileW.restype = wintypes.HANDLE
|
|
258
|
+
self._api.GetFileInformationByHandleEx.argtypes = (
|
|
259
|
+
wintypes.HANDLE,
|
|
260
|
+
ctypes.c_int,
|
|
261
|
+
wintypes.LPVOID,
|
|
262
|
+
wintypes.DWORD,
|
|
263
|
+
)
|
|
264
|
+
self._api.GetFileInformationByHandleEx.restype = wintypes.BOOL
|
|
265
|
+
self._api.SetFileInformationByHandle.argtypes = (
|
|
266
|
+
wintypes.HANDLE,
|
|
267
|
+
ctypes.c_int,
|
|
268
|
+
wintypes.LPVOID,
|
|
269
|
+
wintypes.DWORD,
|
|
270
|
+
)
|
|
271
|
+
self._api.SetFileInformationByHandle.restype = wintypes.BOOL
|
|
272
|
+
self._api.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
273
|
+
self._api.CloseHandle.restype = wintypes.BOOL
|
|
274
|
+
|
|
275
|
+
def open(self, path: Path, *, directory: bool) -> int:
|
|
276
|
+
delete = 0x00010000
|
|
277
|
+
read_attributes = 0x00000080
|
|
278
|
+
write_attributes = 0x00000100
|
|
279
|
+
list_directory = 0x00000001 if directory else 0
|
|
280
|
+
share_read_write = 0x00000001 | 0x00000002
|
|
281
|
+
open_existing = 3
|
|
282
|
+
backup_semantics = 0x02000000
|
|
283
|
+
open_reparse_point = 0x00200000
|
|
284
|
+
handle = self._api.CreateFileW(
|
|
285
|
+
str(path),
|
|
286
|
+
delete | read_attributes | write_attributes | list_directory,
|
|
287
|
+
share_read_write,
|
|
288
|
+
None,
|
|
289
|
+
open_existing,
|
|
290
|
+
backup_semantics | open_reparse_point,
|
|
291
|
+
None,
|
|
292
|
+
)
|
|
293
|
+
if handle == self._invalid_handle:
|
|
294
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
295
|
+
return int(handle)
|
|
296
|
+
|
|
297
|
+
def open_identity(self, path: Path) -> int:
|
|
298
|
+
read_attributes = 0x00000080
|
|
299
|
+
share_all = 0x00000001 | 0x00000002 | 0x00000004
|
|
300
|
+
open_existing = 3
|
|
301
|
+
backup_semantics = 0x02000000
|
|
302
|
+
open_reparse_point = 0x00200000
|
|
303
|
+
handle = self._api.CreateFileW(
|
|
304
|
+
str(path),
|
|
305
|
+
read_attributes,
|
|
306
|
+
share_all,
|
|
307
|
+
None,
|
|
308
|
+
open_existing,
|
|
309
|
+
backup_semantics | open_reparse_point,
|
|
310
|
+
None,
|
|
311
|
+
)
|
|
312
|
+
if handle == self._invalid_handle:
|
|
313
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
314
|
+
return int(handle)
|
|
315
|
+
|
|
316
|
+
def information(self, handle: int) -> tuple[int, int, int]:
|
|
317
|
+
identity = _FileIdInformation()
|
|
318
|
+
if not self._api.GetFileInformationByHandleEx(
|
|
319
|
+
handle, 18, ctypes.byref(identity), ctypes.sizeof(identity)
|
|
320
|
+
):
|
|
321
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
322
|
+
attributes = _FileAttributeTagInformation()
|
|
323
|
+
if not self._api.GetFileInformationByHandleEx(
|
|
324
|
+
handle, 9, ctypes.byref(attributes), ctypes.sizeof(attributes)
|
|
325
|
+
):
|
|
326
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
327
|
+
file_id = int.from_bytes(bytes(identity.file_id), "little")
|
|
328
|
+
return identity.volume_serial, file_id, attributes.file_attributes
|
|
329
|
+
|
|
330
|
+
def entry_batch(self, handle: int) -> list[_WindowsEntry]:
|
|
331
|
+
information_class = 20
|
|
332
|
+
while True:
|
|
333
|
+
result: list[_WindowsEntry] = []
|
|
334
|
+
buffer = ctypes.create_string_buffer(_WINDOWS_DIRECTORY_BUFFER_BYTES)
|
|
335
|
+
if not self._api.GetFileInformationByHandleEx(
|
|
336
|
+
handle, information_class, buffer, len(buffer)
|
|
337
|
+
):
|
|
338
|
+
error = ctypes.get_last_error()
|
|
339
|
+
if error == self._error_no_more_files:
|
|
340
|
+
return []
|
|
341
|
+
raise ctypes.WinError(error)
|
|
342
|
+
information_class = 19
|
|
343
|
+
offset = 0
|
|
344
|
+
while True:
|
|
345
|
+
address = ctypes.addressof(buffer) + offset
|
|
346
|
+
entry = _FileIdExtdDirectoryInformation.from_address(address)
|
|
347
|
+
if entry.file_name_length % 2:
|
|
348
|
+
raise OSError("directory name encoding invalid")
|
|
349
|
+
name_address = (
|
|
350
|
+
address + _FileIdExtdDirectoryInformation.file_name.offset
|
|
351
|
+
)
|
|
352
|
+
name_units = entry.file_name_length // 2
|
|
353
|
+
if (
|
|
354
|
+
name_address + entry.file_name_length
|
|
355
|
+
> ctypes.addressof(buffer) + len(buffer)
|
|
356
|
+
):
|
|
357
|
+
raise OSError("directory name exceeds enumeration buffer")
|
|
358
|
+
raw_name = (ctypes.c_uint16 * name_units).from_address(name_address)
|
|
359
|
+
name = bytes(raw_name).decode("utf-16-le", "strict")
|
|
360
|
+
if name not in {".", ".."}:
|
|
361
|
+
if not _valid_entry_name(name):
|
|
362
|
+
raise OSError("unsafe directory entry")
|
|
363
|
+
result.append(
|
|
364
|
+
_WindowsEntry(
|
|
365
|
+
name,
|
|
366
|
+
int.from_bytes(bytes(entry.file_id), "little"),
|
|
367
|
+
entry.file_attributes,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
if entry.next_entry_offset == 0:
|
|
371
|
+
break
|
|
372
|
+
if entry.next_entry_offset < _FileIdExtdDirectoryInformation.file_name.offset:
|
|
373
|
+
raise OSError("directory enumeration offset invalid")
|
|
374
|
+
offset += entry.next_entry_offset
|
|
375
|
+
if offset >= len(buffer):
|
|
376
|
+
raise OSError("directory enumeration overflow")
|
|
377
|
+
if result:
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
def _set_basic_information(self, handle: int, details: _FileBasicInformation) -> None:
|
|
381
|
+
if not self._api.SetFileInformationByHandle(
|
|
382
|
+
handle, 0, ctypes.byref(details), ctypes.sizeof(details)
|
|
383
|
+
):
|
|
384
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
385
|
+
|
|
386
|
+
def _clear_readonly(self, handle: int) -> None:
|
|
387
|
+
details = _FileBasicInformation()
|
|
388
|
+
if not self._api.GetFileInformationByHandleEx(
|
|
389
|
+
handle, 0, ctypes.byref(details), ctypes.sizeof(details)
|
|
390
|
+
):
|
|
391
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
392
|
+
if details.file_attributes & self._readonly_attribute:
|
|
393
|
+
details.file_attributes &= ~self._readonly_attribute
|
|
394
|
+
self._set_basic_information(handle, details)
|
|
395
|
+
|
|
396
|
+
def mark_delete(self, handle: int) -> None:
|
|
397
|
+
extended = _FileDispositionInformationEx(0x1 | 0x2 | 0x10)
|
|
398
|
+
if self._api.SetFileInformationByHandle(
|
|
399
|
+
handle, 21, ctypes.byref(extended), ctypes.sizeof(extended)
|
|
400
|
+
):
|
|
401
|
+
return
|
|
402
|
+
error = ctypes.get_last_error()
|
|
403
|
+
if error not in self._fallback_errors:
|
|
404
|
+
raise ctypes.WinError(error)
|
|
405
|
+
self._clear_readonly(handle)
|
|
406
|
+
fallback = _FileDispositionInformation(1)
|
|
407
|
+
if not self._api.SetFileInformationByHandle(
|
|
408
|
+
handle, 4, ctypes.byref(fallback), ctypes.sizeof(fallback)
|
|
409
|
+
):
|
|
410
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
411
|
+
|
|
412
|
+
def close(self, handle: int) -> None:
|
|
413
|
+
if handle and not self._api.CloseHandle(handle):
|
|
414
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _delete_windows_handle(
|
|
418
|
+
path: Path,
|
|
419
|
+
handle: int,
|
|
420
|
+
expected_volume: int,
|
|
421
|
+
expected_file_id: int,
|
|
422
|
+
expected_attributes: int,
|
|
423
|
+
backend: _WindowsBackend,
|
|
424
|
+
) -> None:
|
|
425
|
+
volume, file_id, attributes = backend.information(handle)
|
|
426
|
+
if (
|
|
427
|
+
volume != expected_volume
|
|
428
|
+
or file_id != expected_file_id
|
|
429
|
+
or bool(attributes & backend.directory_attribute)
|
|
430
|
+
!= bool(expected_attributes & backend.directory_attribute)
|
|
431
|
+
):
|
|
432
|
+
raise OSError("Windows entry identity changed")
|
|
433
|
+
if attributes & backend.reparse_attribute:
|
|
434
|
+
backend.mark_delete(handle)
|
|
435
|
+
return
|
|
436
|
+
if attributes & backend.directory_attribute:
|
|
437
|
+
while batch := backend.entry_batch(handle):
|
|
438
|
+
for entry in batch:
|
|
439
|
+
child_path = path / entry.name
|
|
440
|
+
child_is_directory = bool(
|
|
441
|
+
entry.attributes & backend.directory_attribute
|
|
442
|
+
and not entry.attributes & backend.reparse_attribute
|
|
443
|
+
)
|
|
444
|
+
child_handle = backend.open(child_path, directory=child_is_directory)
|
|
445
|
+
try:
|
|
446
|
+
_delete_windows_handle(
|
|
447
|
+
child_path,
|
|
448
|
+
child_handle,
|
|
449
|
+
expected_volume,
|
|
450
|
+
entry.file_id,
|
|
451
|
+
entry.attributes,
|
|
452
|
+
backend,
|
|
453
|
+
)
|
|
454
|
+
finally:
|
|
455
|
+
backend.close(child_handle)
|
|
456
|
+
backend.mark_delete(handle)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _delete_windows(
|
|
460
|
+
path: Path,
|
|
461
|
+
expected: tuple[int, int],
|
|
462
|
+
backend: _WindowsBackend | None = None,
|
|
463
|
+
) -> None:
|
|
464
|
+
if not path.is_absolute() or not _valid_entry_name(path.name):
|
|
465
|
+
raise OSError("cleanup path invalid")
|
|
466
|
+
selected = backend or _NativeWindowsBackend()
|
|
467
|
+
handle = selected.open(path, directory=True)
|
|
468
|
+
try:
|
|
469
|
+
volume, file_id, attributes = selected.information(handle)
|
|
470
|
+
if (
|
|
471
|
+
volume != expected[0]
|
|
472
|
+
or file_id != expected[1]
|
|
473
|
+
or not attributes & selected.directory_attribute
|
|
474
|
+
or attributes & selected.reparse_attribute
|
|
475
|
+
):
|
|
476
|
+
raise OSError("cleanup lease changed")
|
|
477
|
+
_delete_windows_handle(path, handle, volume, file_id, attributes, selected)
|
|
478
|
+
finally:
|
|
479
|
+
selected.close(handle)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _windows_directory_identity(path: Path) -> tuple[int, int]:
|
|
483
|
+
"""Capture a directory's 64-bit volume and 128-bit FileIdInfo identity."""
|
|
484
|
+
backend = _NativeWindowsBackend()
|
|
485
|
+
handle = backend.open_identity(path)
|
|
486
|
+
try:
|
|
487
|
+
volume, file_id, attributes = backend.information(handle)
|
|
488
|
+
if (
|
|
489
|
+
not attributes & backend.directory_attribute
|
|
490
|
+
or attributes & backend.reparse_attribute
|
|
491
|
+
):
|
|
492
|
+
raise OSError("Windows cleanup lease is not a plain directory")
|
|
493
|
+
return volume, file_id
|
|
494
|
+
finally:
|
|
495
|
+
backend.close(handle)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _parse_arguments(argv: list[str]) -> tuple[Path, tuple[int, int]]:
|
|
499
|
+
if len(argv) != 4:
|
|
500
|
+
raise ValueError("cleanup arguments invalid")
|
|
501
|
+
path = Path(argv[1])
|
|
502
|
+
device = int(argv[2], 10)
|
|
503
|
+
inode = int(argv[3], 10)
|
|
504
|
+
if device < 0 or inode < 0 or not path.is_absolute():
|
|
505
|
+
raise ValueError("cleanup arguments invalid")
|
|
506
|
+
return path, (device, inode)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def main(argv: list[str] | None = None) -> int:
|
|
510
|
+
try:
|
|
511
|
+
path, expected = _parse_arguments(sys.argv if argv is None else argv)
|
|
512
|
+
if os.name == "nt":
|
|
513
|
+
_delete_windows(path, expected)
|
|
514
|
+
else:
|
|
515
|
+
_delete_posix(path, expected)
|
|
516
|
+
return EXIT_POSIX_ROOT_RETAINED
|
|
517
|
+
return 0
|
|
518
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
519
|
+
return EXIT_UNSAFE
|
|
520
|
+
except Exception:
|
|
521
|
+
return EXIT_FAILED
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
raise SystemExit(main())
|