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
49 changes: 40 additions & 9 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ helpers for independent project/source/managed boundaries.
| Create directory | Reject accessed links before creating parents | Follow contained directory links; reject dangling targets |
| Exclusively create file | Reject accessed links | Follow contained parents; reject an existing leaf without opening its target |
| Atomic write/upsert | Reject accessed links | Replace the resolved regular-file target and preserve every link |
| Non-atomic text overwrite/upsert | Reject accessed links | Update the resolved regular-file target in place and preserve every link |
| Delete leaf link | Reject without mutation | Unlink the link, including dangling, cyclic, or external-target links |
| Delete through linked parent | Reject without mutation | Delete the contained target entry, leaving the parent link intact |
| Recursive delete | Preflight all descendants and reject any link before mutation | Preflight all descendants; unlink links without traversing their targets |
Expand Down Expand Up @@ -195,6 +196,32 @@ helpers for independent project/source/managed boundaries.
translation match the previous configuration writer. An encoding or I/O
failure propagates, and a write failure can leave truncated/partial content.
This is not hard-link confinement: a hard-linked alias can observe updates.
After policy resolution, POSIX opens each resolved directory relative to an
already-open parent descriptor with no-follow flags, using search/path-only
access where supported so unreadable but searchable parents remain usable,
and closes the previous directory after opening its child. Descriptor usage
stays bounded independently of path depth. It then opens the leaf without
truncation. Windows pins the canonical root and every resolved parent with
non-reparse handles withholding delete sharing. Each anchored directory must
permit `FILE_LIST_DIRECTORY` and `FILE_READ_ATTRIBUTES` access. An ACL that
permits file writing but denies directory listing can therefore cause an
overwrite to fail before opening the leaf. Existing non-reparse leaves are
opened without truncation. For a missing leaf, an empty, randomly named
CRT-created probe in an independently owned runtime temporary directory
captures the masked creation attributes without changing the process mask. This scratch root is
pinned separately, contains no project data, and is removed before native
no-follow exclusive creation of the destination. No disposable sibling is
created in the project, so destination creation does not require project
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.
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.
- `delete(path, recursive=False)` unlinks files/links or removes directories.
Nonrecursive directory deletion requires an empty directory. The established
root cannot be deleted.
Expand Down Expand Up @@ -225,12 +252,11 @@ entries, not reject a whole project for excluded/unrelated links.

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. For all operations that still use
validated paths (reads, directory creation, atomic writes, non-atomic
overwrites, symlink creation, and deletion), portable path checks are **not**
a race-free sandbox: another process can exchange parents or entries between
checks and syscalls, and an I/O failure during mutation can leave partial
results. Protect against concurrent untrusted mutation separately.
deletion rechecks each accessed entry. No operation is a general race-free
sandbox. For operations that still use validated paths, another process can
exchange parents or entries between checks and syscalls. An I/O failure during
any mutation can leave partial results. Protect against concurrent untrusted
mutation separately.
Exclusive file creation anchors its resolved parent hierarchy as described
above. On POSIX, renaming an opened directory does not redirect creation to
its replacement: creation stays with the opened directory, even if it is
Expand All @@ -244,9 +270,14 @@ infrastructure warning/skip behavior and development-mode link creation are
unchanged. Migrate one owned operation boundary at a time, carrying original
paths and an explicit root/policy into the helper before any early resolution.
Linked command directories and development-mode registration remain dependent
stages. Non-atomic configuration writes preserve legacy metadata semantics;
portable preflight does not guarantee race-free protection against concurrent
path replacement.
stages. Non-atomic configuration writes preserve legacy metadata semantics.
`overwrite_text` keeps the resolved directory/file objects open, but does not
freeze directory membership on POSIX: an opened directory or file may be
renamed, and the operation continues against that opened object. The trusted
root's OS ancestors remain outside the helper's policy boundary. This is not
a general sandbox against concurrent namespace changes or hard-linked aliases.
Platforms without the required descriptor/handle primitives fail explicitly
rather than falling back to path-based overwrite.

Windows path classification and directory-mode reparse rejection have portable
regression tests. Native Windows path and junction tests also exist and are
Expand Down
115 changes: 88 additions & 27 deletions src/specify_cli/_file_handles.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
"""Private platform handles for confined filesystem operations."""

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


@contextmanager
def _windows_handle(path: Path, *, directory: bool) -> Iterator[int]:
"""Pin a non-reparse path by withholding Windows delete sharing."""
def _windows_api():
import ctypes
from ctypes import wintypes

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

kernel = ctypes.WinDLL("kernel32", use_last_error=True)
create = kernel.CreateFileW
create.argtypes = [
kernel.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
Expand All @@ -26,38 +24,101 @@ class AttributeTagInfo(ctypes.Structure):
wintypes.DWORD,
wintypes.HANDLE,
]
create.restype = wintypes.HANDLE
info = kernel.GetFileInformationByHandleEx
info.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD]
info.restype = wintypes.BOOL
close = kernel.CloseHandle
close.argtypes = [wintypes.HANDLE]
close.restype = wintypes.BOOL

# OPEN_REPARSE_POINT inspects the link itself; BACKUP_SEMANTICS permits
# directory handles. Holding every ancestor without FILE_SHARE_DELETE
# prevents a junction/symlink swap during path-based create/replace.
# Attribute-only handles bypass share checking: include FILE_LIST_DIRECTORY
# with FILE_READ_ATTRIBUTES so even an empty directory cannot be renamed.
handle = create(
kernel.CreateFileW.restype = wintypes.HANDLE
kernel.GetFileInformationByHandleEx.argtypes = [
wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD
]
kernel.GetFileInformationByHandleEx.restype = wintypes.BOOL
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.CloseHandle.restype = wintypes.BOOL
return kernel


def _open_windows_handle(
path: Path, *, directory: bool, overwrite: bool = False, create_attributes: int | None = None
) -> int:
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),
(0x80 | 0x1) if directory else 0x80000000,
(0x40000000 | 0x80) if overwrite else ((0x80 | 0x1) if directory else 0x80000000),
0x3,
None,
3,
0x00200000 | 0x02000000,
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())
try:
attributes = AttributeTagInfo()
if not info(handle, 9, ctypes.byref(attributes), ctypes.sizeof(attributes)):
if not kernel.GetFileInformationByHandleEx(
handle, 9, ctypes.byref(attributes), ctypes.sizeof(attributes)
):
raise ctypes.WinError(ctypes.get_last_error())
if attributes.attributes & 0x400:
raise OSError(errno.ELOOP, "Refusing a reparse-point filesystem path", str(path))
if bool(attributes.attributes & 0x10) != directory:
raise OSError(errno.EINVAL, "Unexpected filesystem object type", str(path))
return handle
except BaseException:
kernel.CloseHandle(handle)
raise


@contextmanager
def _windows_handle(path: Path, *, directory: bool) -> Iterator[int]:
"""Pin a non-reparse path by withholding Windows delete sharing."""
handle = _open_windows_handle(path, directory=directory)
try:
yield handle
finally:
close(handle)
_windows_api().CloseHandle(handle)


@contextmanager
def _windows_overwrite_fd(path: Path) -> Iterator[int]:
"""Open/create without following a new leaf or changing the CRT mask."""
try:
handle = _open_windows_handle(path, directory=False, overwrite=True)
except FileNotFoundError:
attributes = _windows_creation_attributes()
handle = _open_windows_handle(
path, directory=False, overwrite=True, create_attributes=attributes
)
try:
import msvcrt

fd = msvcrt.open_osfhandle(handle, os.O_WRONLY | os.O_BINARY)
except BaseException:
_windows_api().CloseHandle(handle)
raise
try:
yield fd
finally:
os.close(fd)


def _windows_creation_attributes() -> int:
"""Capture CRT-masked permissions in independent runtime-owned storage."""
with tempfile.TemporaryDirectory(prefix="specify-mode-") as directory:
parent = Path(directory)
with _windows_handle(parent, directory=True):
probe = parent / f".specify-mode-{secrets.token_hex(16)}.tmp"
fd = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_BINARY, 0o666)
try:
readonly = not os.fstat(fd).st_mode & stat.S_IWRITE
finally:
try:
os.chmod(probe, 0o600)
finally:
os.close(fd)
os.unlink(probe)
return 0x1 if readonly else 0
69 changes: 58 additions & 11 deletions src/specify_cli/file_helper.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
"""Scoped filesystem operations; see design/file-helper.md for the contract."""

from dataclasses import dataclass, field
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
import codecs
import errno
import os
from pathlib import Path, PurePath, PureWindowsPath
import stat
import sys
import tempfile
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass, field
from pathlib import Path, PurePath, PureWindowsPath
from typing import TypeVar

from specify_cli._file_handles import _windows_handle

from specify_cli._file_handles import _windows_handle, _windows_overwrite_fd

_PathType = TypeVar("_PathType", bound=PurePath)
_OVERWRITE_DIR_FD_SUPPORTED = os.open in os.supports_dir_fd


def _strip_extended_length_prefix(path: _PathType) -> _PathType:
Expand Down Expand Up @@ -266,11 +266,58 @@ def overwrite_text(
if destination.exists():
self._require_file(destination)
codecs.lookup(encoding)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(destination, flags, 0o666)
with os.fdopen(fd, "w", encoding=encoding) as stream:
stream.write(content)
with self._overwrite_descriptor(destination) as fd:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise UnsupportedPathError(f"Expected a regular file: {destination}")
os.ftruncate(fd, 0)
with os.fdopen(fd, "w", encoding=encoding, closefd=False) as stream:
stream.write(content)

@contextmanager
def _overwrite_descriptor(self, destination: Path) -> Iterator[int]:
"""Anchor the resolved hierarchy before opening a non-truncating leaf."""
parts = destination.relative_to(self._canonical_root).parts
with ExitStack() as stack:
if os.name == "nt":
current = self._canonical_root
stack.enter_context(_windows_handle(current, directory=True))
for part in parts[:-1]:
current = current / part
stack.enter_context(_windows_handle(current, directory=True))
yield stack.enter_context(_windows_overwrite_fd(destination))
Comment thread
mnriem marked this conversation as resolved.
Resolved
return

if not (
_OVERWRITE_DIR_FD_SUPPORTED
and getattr(os, "O_NOFOLLOW", 0)
and getattr(os, "O_DIRECTORY", 0)
and getattr(os, "O_NONBLOCK", 0)
):
raise OSError(errno.ENOTSUP, "Scoped text overwrite is unsupported on this platform")
directory_access = getattr(os, "O_SEARCH", getattr(os, "O_PATH", None))
if directory_access is None:
# Older Darwin Python builds omit the kernel's O_EXEC/O_SEARCH bit.
directory_access = 0x40000000 if sys.platform == "darwin" else os.O_RDONLY
directory_flags = (
directory_access | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
)
parent = os.open(self._canonical_root, directory_flags)
try:
for part in parts[:-1]:
child = os.open(part, directory_flags, dir_fd=parent)
os.close(parent)
parent = child
flags = (
os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK
| getattr(os, "O_CLOEXEC", 0)
)
fd = os.open(parts[-1], flags, 0o666, dir_fd=parent)
try:
yield fd
finally:
os.close(fd)
finally:
os.close(parent)

def symlink(self, target: Path | str, path: Path | str) -> None:
"""Create a link to an existing contained regular file or directory.
Expand Down
Loading
Loading