Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,10 @@ helpers for independent project/source/managed boundaries.
deletion rights. Runtime temporary-storage failures remain explicit errors.
A leaf introduced between these calls causes an explicit creation error.
The opened leaf remains held through text I/O. Both paths check the opened
file type before truncating that same descriptor. An exchanged parent cannot
redirect creation or truncation through a new link. Allowed links are still
resolved by the normal policy walk; the anchored traversal operates on their
resolved target, not on the original link spelling.
file type before truncating that same descriptor. On POSIX, an exchanged
parent cannot redirect creation or truncation through a new link. Allowed
links are still resolved by the normal policy walk; the anchored traversal
operates on their resolved target, not on the original link spelling.
Windows creation also honors the CRT process mask: masking the owner-write
bit creates a read-only file, matching native text creation, without changing
the attributes of an existing file.
Expand Down Expand Up @@ -250,6 +250,37 @@ entries, not reject a whole project for excluded/unrelated links.

## Safety limits and migration

POSIX reads, exclusive file creation, and text overwrites share one parent
directory walk for capability checks, search flags, no-follow traversal, and
descriptor cleanup. Leaf open flags, file-type validation, and truncation
remain operation-specific.

Reads resolve supported contained links under the existing policy, then open
the resolved hierarchy without following substituted links. POSIX reads use
descriptor-relative directory traversal with `O_DIRECTORY` and `O_NOFOLLOW`.
Directory descriptors use `O_SEARCH` or `O_PATH` where available so searchable
directories need not be listable, including Darwin's native `O_SEARCH` value
when a Python build uses an older SDK that omits the constant. Each superseded
parent is closed as soon as its child is open. The leaf uses `O_NOFOLLOW` and
`O_NONBLOCK`. Windows reads open the canonical root with `FILE_TRAVERSE` and
`FILE_READ_ATTRIBUTES`, then open each resolved child relative to its validated
parent handle with `NtCreateFile`. Directory opens do not request enumeration
access. Relative opens prevent an in-place junction change on an already-open
parent from redirecting the next open; write-sharing restrictions alone cannot
prevent attribute-only reparse updates. Each new handle rejects reparse points,
and handles withhold delete sharing. Reads duplicate the validated leaf handle
into an independently owned descriptor, never reopen its pathname.
Both paths validate the opened file with `fstat` before reading its descriptor,
and release all handles on success or failure. Windows child-open ABI
structures and pointer types are initialized lazily in a Windows-only module
and reused through Python's import cache. This avoids type retention in older
supported Python versions without requiring `ctypes` for POSIX startup.
Handle values and path buffers remain operation-local. Platforms without the
required safe-open primitives fail explicitly rather than fall back to
unchecked pathname reads. A concurrent replacement can fail the read or leave
it reading the already-open original file, but cannot redirect it through a
substituted symlink. This does not freeze file contents against in-place edits.

Preflight detects static policy failures before recursive deletion or parent
creation. Atomic writes recheck the original path before replacement, and
deletion rechecks each accessed entry. No operation is a general race-free
Expand All @@ -262,8 +293,12 @@ above. On POSIX, renaming an opened directory does not redirect creation to
its replacement: creation stays with the opened directory, even if it is
renamed out of the root. This is not a guarantee of stable namespace membership
under concurrent renames, nor does parent anchoring make every leaf preflight
atomic with the subsequent open. The helper does not constrain subprocesses
or third-party code.
atomic with the subsequent open.
On Windows, exclusive creation and text overwrite still open their leaf by
pathname after pinning the parent hierarchy. Attribute-only reparse updates
can redirect those opens; they do not have the read path's parent-relative
handle protection.
The helper does not constrain subprocesses or third-party code.

Existing safe-write mechanisms informed this helper, but existing shared
infrastructure warning/skip behavior and development-mode link creation are
Expand Down
116 changes: 97 additions & 19 deletions src/specify_cli/_file_handles.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,61 @@
"""Private platform handles for confined filesystem operations."""

from collections.abc import Iterator
from contextlib import contextmanager
import errno
import os
import secrets
import stat
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from pathlib import Path, PureWindowsPath


def _open_windows_child(parent: int, name: str, *, directory: bool, access: int, sharing: int) -> int:
"""Open one component relative to an already validated directory handle."""
if PureWindowsPath(name).parts != (name,) or PureWindowsPath(name).anchor or name == ".." or "\0" in name:
raise ValueError(f"Expected a single relative Windows path component: {name!r}")
encoded_length = len(name.encode("utf-16-le", errors="surrogatepass"))
if encoded_length > 65532:
raise OSError(errno.ENAMETOOLONG, "Windows path component is too long", name)

import ctypes
from ctypes import wintypes
from . import _windows_abi as abi

native = ctypes.WinDLL("ntdll")
create = native.NtCreateFile
create.argtypes = [
ctypes.POINTER(wintypes.HANDLE), ctypes.c_uint32,
abi._ObjectAttributesPointer, abi._IoStatusBlockPointer,
wintypes.LPVOID, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.c_uint32, wintypes.LPVOID, ctypes.c_uint32,
]
create.restype = ctypes.c_int32
dos_error = native.RtlNtStatusToDosError
dos_error.argtypes = [ctypes.c_int32]
dos_error.restype = ctypes.c_uint32
buffer = ctypes.create_unicode_buffer(name)
unicode_name = abi._UnicodeString(
encoded_length, encoded_length + 2, ctypes.cast(buffer, wintypes.LPWSTR)
)
# OBJ_DONT_REPARSE and FILE_OPEN_REPARSE_POINT keep resolution on this handle.
attributes = abi._ObjectAttributes(
ctypes.sizeof(abi._ObjectAttributes), parent, ctypes.pointer(unicode_name),
0x40 | 0x1000, None, None,
)
handle = wintypes.HANDLE()
status = abi._IoStatusBlock()
options = 0x00200000 | (0x1 if directory else 0x40 | 0x20)
result = create(
ctypes.byref(handle), access | (0 if directory else 0x100000),
ctypes.byref(attributes), ctypes.byref(status), None, 0,
sharing, 1, options, None, 0,
)
if result < 0:
raise ctypes.WinError(dos_error(result))
if not handle.value:
raise OSError(errno.EBADF, "Native child open returned no handle", name)
return handle.value


def _windows_api():
Expand Down Expand Up @@ -35,28 +83,32 @@ def _windows_api():


def _open_windows_handle(
path: Path, *, directory: bool, overwrite: bool = False, create_attributes: int | None = None
path: Path, *, directory: bool, overwrite: bool = False,
create_attributes: int | None = None, parent: int | None = None, traverse: bool = False,
) -> int:
if parent is not None and (overwrite or create_attributes is not None):
raise ValueError("Relative Windows handle opens do not support creation or overwrite")
import ctypes
from ctypes import wintypes

class AttributeTagInfo(ctypes.Structure):
_fields_ = [("attributes", wintypes.DWORD), ("tag", wintypes.DWORD)]

kernel = _windows_api()
# Attribute-only directory handles do not prevent renaming; include
# FILE_LIST_DIRECTORY and withhold FILE_SHARE_DELETE for every ancestor.
handle = kernel.CreateFileW(
str(path),
(0x40000000 | 0x80) if overwrite else ((0x80 | 0x1) if directory else 0x80000000),
0x3,
None,
3 if create_attributes is None else 1, # OPEN_EXISTING or CREATE_NEW.
0x00200000 | 0x02000000 | (create_attributes or 0),
None,
)
if handle == wintypes.HANDLE(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
access = (0x80 | (0x20 if traverse else 0x1)) if directory else 0x80000000
if overwrite:
access = 0x40000000 | 0x80
if parent is None:
# FILE_TRAVERSE participates in sharing checks without requiring enumeration.
handle = kernel.CreateFileW(
str(path), access, 0x3, None,
3 if create_attributes is None else 1, # OPEN_EXISTING or CREATE_NEW.
0x00200000 | 0x02000000 | (create_attributes or 0), None,
)
if handle == wintypes.HANDLE(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
else:
handle = _open_windows_child(parent, str(path), directory=directory, access=access, sharing=0x3)
try:
attributes = AttributeTagInfo()
if not kernel.GetFileInformationByHandleEx(
Expand All @@ -74,15 +126,41 @@ class AttributeTagInfo(ctypes.Structure):


@contextmanager
def _windows_handle(path: Path, *, directory: bool) -> Iterator[int]:
def _windows_handle(
path: Path, *, directory: bool, parent: int | None = None, traverse: bool = False,
) -> Iterator[int]:
"""Pin a non-reparse path by withholding Windows delete sharing."""
handle = _open_windows_handle(path, directory=directory)
if parent is None and not traverse:
handle = _open_windows_handle(path, directory=directory)
else:
handle = _open_windows_handle(path, directory=directory, parent=parent, traverse=traverse)
try:
yield handle
finally:
_windows_api().CloseHandle(handle)


@contextmanager
def windows_read_fd(handle: int) -> Iterator[int]:
"""Read the validated object through an independently owned duplicate."""
import _winapi
import msvcrt

process = _winapi.GetCurrentProcess()
duplicate = _winapi.DuplicateHandle(
process, handle, process, 0, False, _winapi.DUPLICATE_SAME_ACCESS
)
try:
fd = msvcrt.open_osfhandle(duplicate, os.O_RDONLY | os.O_BINARY)
except BaseException:
_winapi.CloseHandle(duplicate)
raise
try:
yield fd
finally:
os.close(fd)


@contextmanager
def _windows_overwrite_fd(path: Path) -> Iterator[int]:
"""Open/create without following a new leaf or changing the CRT mask."""
Expand Down
41 changes: 41 additions & 0 deletions src/specify_cli/_windows_abi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Windows ABI metadata, initialized once through lazy module imports."""

import ctypes
from ctypes import wintypes


# Older Python versions retain POINTER types globally; reuse these ABI classes.
class _UnicodeString(ctypes.Structure):
_fields_ = [
("Length", ctypes.c_uint16),
("MaximumLength", ctypes.c_uint16),
("Buffer", wintypes.LPWSTR),
]


_UnicodeStringPointer = ctypes.POINTER(_UnicodeString)


class _ObjectAttributes(ctypes.Structure):
_fields_ = [
("Length", ctypes.c_uint32),
("RootDirectory", wintypes.HANDLE),
("ObjectName", _UnicodeStringPointer),
("Attributes", ctypes.c_uint32),
("SecurityDescriptor", wintypes.LPVOID),
("SecurityQualityOfService", wintypes.LPVOID),
]


_ObjectAttributesPointer = ctypes.POINTER(_ObjectAttributes)


class _StatusValue(ctypes.Union):
_fields_ = [("Status", ctypes.c_int32), ("Pointer", wintypes.LPVOID)]


class _IoStatusBlock(ctypes.Structure):
_fields_ = [("Value", _StatusValue), ("Information", ctypes.c_size_t)]


_IoStatusBlockPointer = ctypes.POINTER(_IoStatusBlock)
Loading
Loading