1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/_fs_safety.py

367 lines
11 KiB
Python

"""Internal directory binding helpers for disposable publication paths."""
from __future__ import annotations
import ctypes
import errno
import os
import secrets
import stat
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import Protocol, cast
from .errors import DocForgeError
RENAME_NOREPLACE = 1
RENAME_EXCHANGE = 2
class _RenameAt2(Protocol):
argtypes: list[object]
restype: object
def __call__(
self,
old_directory_fd: int,
old_name: bytes,
new_directory_fd: int,
new_name: bytes,
flags: int,
/,
) -> int: ...
def _rename_at2(
old_directory_fd: int,
old_name: str,
new_directory_fd: int,
new_name: str,
flags: int,
) -> int:
library = ctypes.CDLL(None, use_errno=True)
try:
rename_at2 = cast(_RenameAt2, library.renameat2)
except AttributeError as error:
raise DocForgeError(
"atomic_exchange_unavailable",
"Atomic exchange is unavailable on this platform",
) from error
rename_at2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
rename_at2.restype = ctypes.c_int
ctypes.set_errno(0)
result = rename_at2(
old_directory_fd,
os.fsencode(old_name),
new_directory_fd,
os.fsencode(new_name),
flags,
)
if result == 0:
return 0
error_number = ctypes.get_errno()
if error_number in {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP}:
raise DocForgeError(
"atomic_exchange_unavailable",
"Atomic exchange is unavailable on this filesystem",
)
return error_number
def rename_exchange_between_at(
first_directory_fd: int,
first: str,
second_directory_fd: int,
second: str,
) -> None:
"""Atomically exchange names between two bound directories on one filesystem."""
error_number = _rename_at2(
first_directory_fd,
first,
second_directory_fd,
second,
RENAME_EXCHANGE,
)
if error_number == 0:
return
raise DocForgeError(
"publication_failure",
"Could not exchange atomic publication paths",
error_number=error_number,
) from OSError(error_number, os.strerror(error_number))
def rename_exchange_at(directory_fd: int, first: str, second: str) -> None:
"""Atomically exchange two names inside one already bound directory."""
rename_exchange_between_at(directory_fd, first, directory_fd, second)
def rename_noreplace_between_at(
source_directory_fd: int,
source: str,
target_directory_fd: int,
target: str,
) -> bool:
"""Atomically move one name without replacing a target that appeared."""
error_number = _rename_at2(
source_directory_fd,
source,
target_directory_fd,
target,
RENAME_NOREPLACE,
)
if error_number == 0:
return True
if error_number == errno.EEXIST:
return False
raise DocForgeError(
"publication_failure",
"Could not move an atomic publication path without replacement",
error_number=error_number,
) from OSError(error_number, os.strerror(error_number))
def open_bound_directory(path: Path) -> int:
"""Open one real directory and bind its current inode for later operations."""
try:
path_status = path.lstat()
if (
stat.S_ISLNK(path_status.st_mode)
or not stat.S_ISDIR(path_status.st_mode)
or path.resolve(strict=True) != path
):
raise DocForgeError(
"path_escape",
"Derived cache root is not a safe real directory",
)
directory_fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except FileNotFoundError as error:
raise DocForgeError(
"missing_index",
"Derived cache root does not exist",
) from error
except OSError as error:
raise DocForgeError(
"path_escape",
"Derived cache root cannot be opened safely",
) from error
try:
opened_status = os.fstat(directory_fd)
if opened_status.st_dev != path_status.st_dev or opened_status.st_ino != path_status.st_ino:
raise DocForgeError(
"path_escape",
"Derived cache root changed while opening",
)
except Exception:
os.close(directory_fd)
raise
return directory_fd
def require_bound_directory(path: Path, directory_fd: int) -> None:
"""Require a path to still name the exact opened real directory."""
try:
path_status = path.lstat()
opened_status = os.fstat(directory_fd)
if (
stat.S_ISLNK(path_status.st_mode)
or not stat.S_ISDIR(path_status.st_mode)
or path.resolve(strict=True) != path
or opened_status.st_dev != path_status.st_dev
or opened_status.st_ino != path_status.st_ino
):
raise DocForgeError(
"path_escape",
"Derived cache root changed during publication",
)
except FileNotFoundError as error:
raise DocForgeError(
"path_escape",
"Derived cache root disappeared during publication",
) from error
def open_confined_directory(root: Path, path: Path, *, create: bool) -> int:
"""Open a descendant directory through stable no-follow directory descriptors."""
try:
unsafe = (
root.is_symlink()
or root.resolve(strict=True) != root
or not path.is_relative_to(root)
or path == root
)
except OSError as error:
raise DocForgeError("path_escape", "Project root cannot be resolved safely") from error
if unsafe:
raise DocForgeError("path_escape", "Derived output directory is not confined")
relative = path.relative_to(root)
try:
descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError as error:
raise DocForgeError("path_escape", "Project root cannot be opened safely") from error
try:
for part in relative.parts:
if part in {"", ".", ".."}:
raise DocForgeError("path_escape", "Derived output directory is not confined")
if create:
try:
os.mkdir(part, mode=0o700, dir_fd=descriptor)
except FileExistsError:
pass
except OSError as error:
raise DocForgeError(
"publication_failure",
"Derived output directory could not be created",
) from error
try:
next_descriptor = os.open(
part,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=descriptor,
)
except OSError as error:
raise DocForgeError(
"path_escape",
"Derived output directory is missing or unsafe",
) from error
os.close(descriptor)
descriptor = next_descriptor
require_bound_directory(path, descriptor)
return descriptor
except Exception:
os.close(descriptor)
raise
def safe_file_identity_at(
directory: Path,
directory_fd: int,
name: str,
) -> dict[str, object] | None:
"""Return one no-follow regular-file identity relative to a bound directory."""
del directory
if not name or "/" in name or name in {".", ".."}:
raise DocForgeError("path_escape", "Derived artifact name is unsafe")
try:
current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError("path_escape", "Derived artifact cannot be inspected") from error
if not stat.S_ISREG(current.st_mode):
raise DocForgeError("path_escape", "Derived artifact is not a safe regular file")
return {
"path": name,
"device": current.st_dev,
"inode": current.st_ino,
"mode": current.st_mode,
"size": current.st_size,
"mtime_ns": current.st_mtime_ns,
"ctime_ns": current.st_ctime_ns,
}
def read_bounded_file_at(
directory_fd: int,
name: str,
maximum_bytes: int,
) -> bytes | None:
"""Read one regular file through a bound directory without following links."""
try:
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError("path_escape", "Derived artifact cannot be opened safely") from error
with os.fdopen(descriptor, "rb") as handle:
current = os.fstat(handle.fileno())
if not stat.S_ISREG(current.st_mode) or current.st_size > maximum_bytes:
raise DocForgeError("invalid_projection", "Derived artifact is invalid or oversized")
content = handle.read(maximum_bytes + 1)
if len(content) > maximum_bytes:
raise DocForgeError("invalid_projection", "Derived artifact is oversized")
return content
def atomic_replace_bytes_at(
path: Path,
directory_fd: int,
name: str,
content: bytes,
*,
verify: Callable[[], None],
) -> dict[str, object]:
"""Durably replace one file inside an already bound directory."""
if not name or "/" in name or name in {".", ".."}:
raise DocForgeError("path_escape", "Derived artifact name is unsafe")
existing = safe_file_identity_at(path, directory_fd, name)
del existing
temporary = f".docforge-projection-{secrets.token_hex(12)}"
descriptor: int | None = None
committed = False
try:
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600,
dir_fd=directory_fd,
)
with os.fdopen(descriptor, "wb") as handle:
descriptor = None
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
verify()
require_bound_directory(path, directory_fd)
os.replace(
temporary,
name,
src_dir_fd=directory_fd,
dst_dir_fd=directory_fd,
)
committed = True
os.fsync(directory_fd)
identity = safe_file_identity_at(path, directory_fd, name)
if identity is None:
raise DocForgeError(
"publication_failure",
"Derived artifact disappeared after publication",
mutation_committed=True,
)
return identity
except DocForgeError as error:
if committed:
raise DocForgeError(
"publication_failure",
"Derived artifact was replaced but final publication verification failed",
mutation_committed=True,
cause=error.code,
) from error
raise
except OSError as error:
raise DocForgeError(
"publication_failure",
"Derived artifact publication failed",
mutation_committed=committed,
) from error
finally:
if descriptor is not None:
os.close(descriptor)
with suppress(OSError):
os.unlink(temporary, dir_fd=directory_fd)