diff --git a/tests/ipc/test_each_tpt.py b/tests/ipc/test_each_tpt.py index 5d1fdea3..d7908cae 100644 --- a/tests/ipc/test_each_tpt.py +++ b/tests/ipc/test_each_tpt.py @@ -3,14 +3,17 @@ Unit-ish tests for specific IPC transport protocol backends. ''' from __future__ import annotations +import os from pathlib import Path +import stat +import sys import pytest import trio import tractor from tractor import Actor -from tractor.runtime import _state from tractor.discovery import _addr +from tractor.runtime import _state @pytest.fixture @@ -31,6 +34,69 @@ def bindspace_dir_str() -> str: bs_dir.rmdir() +def test_macos_rt_dir_fits_uds_path_limit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + ''' + Keep the default Darwin UDS bindpath below its 104-byte limit. + + `platformdirs` normally places the runtime directory below the + long `~/Library/Caches/TemporaryItems` path. Pytest also assigns + a deeply nested temporary home, so appending a registry socket + name made every macOS UDS listener fail with `AF_UNIX path too + long`. This test simulates Darwin and an intentionally long + platformdirs result, then proves `get_rt_dir()` uses the short + system temporary directory and leaves room for the socket name. + + ''' + long_rt_dir: Path = tmp_path / ('long' * 30) + monkeypatch.setattr(sys, 'platform', 'darwin') + monkeypatch.setattr( + 'platformdirs.user_runtime_dir', + lambda appname: str(long_rt_dir / appname), + ) + monkeypatch.setattr(_state, '_DARWIN_TMPDIR', tmp_path) + rt_dir: Path = _state.get_rt_dir() + sockpath: Path = ( + Path('/tmp') + / f'tractor-{os.getuid()}' + / 'registry@1616.sock' + ) + + assert rt_dir == tmp_path / f'tractor-{os.getuid()}' + assert len(os.fsencode(sockpath)) < 104 + assert stat.S_IMODE(rt_dir.stat().st_mode) == 0o700 + + +def test_macos_rt_dir_rejects_symlink( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + ''' + Reject a pre-created symlink at the Darwin runtime path. + + Darwin uses the predictable `/tmp/tractor-` path to stay + below its `AF_UNIX` limit. A hostile local user could otherwise + point that path at a victim-owned directory and make + `get_rt_dir()` chmod or place sockets in the symlink target. The + test replaces `/tmp` with a controlled directory, installs the + malicious link, and proves non-following validation rejects it. + + ''' + runtime_link: Path = tmp_path / f'tractor-{os.getuid()}' + target_dir: Path = tmp_path / 'target' + target_dir.mkdir(mode=0o755) + runtime_link.symlink_to(target_dir, target_is_directory=True) + monkeypatch.setattr(sys, 'platform', 'darwin') + monkeypatch.setattr(_state, '_DARWIN_TMPDIR', tmp_path) + + with pytest.raises(PermissionError, match='Unsafe Darwin'): + _state.get_rt_dir() + + assert stat.S_IMODE(target_dir.stat().st_mode) == 0o755 + + def test_uds_bindspace_created_implicitly( debug_mode: bool, bindspace_dir_str: str, diff --git a/tractor/runtime/_state.py b/tractor/runtime/_state.py index 0020fd8d..df012cca 100644 --- a/tractor/runtime/_state.py +++ b/tractor/runtime/_state.py @@ -22,7 +22,10 @@ from __future__ import annotations from contextvars import ( ContextVar, ) +import os from pathlib import Path +import stat +import sys from typing import ( Any, Callable, @@ -42,6 +45,9 @@ if TYPE_CHECKING: from .._context import Context +_DARWIN_TMPDIR: Path = Path('/tmp') + + # default IPC transport protocol settings TransportProtocolKey = Literal[ 'tcp', @@ -335,11 +341,44 @@ def get_rt_dir( # `import tractor` path (gh #470). import platformdirs - rt_dir: Path = Path( - platformdirs.user_runtime_dir( - appname=appname, - ), - ) + rt_root: Path|None = None + if sys.platform == 'darwin': + # Darwin's AF_UNIX path limit is 104 bytes. The standard + # platformdirs path can consume that before the sock name. + rt_root = ( + _DARWIN_TMPDIR + / f'{appname}-{os.getuid()}' + ) + try: + rt_stat: os.stat_result = rt_root.lstat() + except FileNotFoundError: + try: + rt_root.mkdir(mode=0o700) + except FileExistsError: + pass + rt_stat = rt_root.lstat() + + if ( + not stat.S_ISDIR(rt_stat.st_mode) + or + rt_stat.st_uid != os.getuid() + ): + raise PermissionError( + f'Unsafe Darwin runtime directory!\n' + f'path: {rt_root}\n' + f'owner uid: {rt_stat.st_uid}\n' + f'mode: {stat.filemode(rt_stat.st_mode)}\n' + ) + if stat.S_IMODE(rt_stat.st_mode) != 0o700: + rt_root.chmod(0o700) + + rt_dir: Path = rt_root + else: + rt_dir = Path( + platformdirs.user_runtime_dir( + appname=appname, + ), + ) # Normalize and validate that `subdir` is a relative path # without any parent-directory ("..") components, to prevent @@ -363,11 +402,30 @@ def get_rt_dir( rt_dir: Path = rt_dir / subdir_path - if not rt_dir.is_dir(): + try: + dir_stat: os.stat_result = rt_dir.lstat() + except FileNotFoundError: rt_dir.mkdir( + mode=0o700, parents=True, exist_ok=True, # avoid `FileExistsError` from conc calls ) + dir_stat = rt_dir.lstat() + + if rt_root is not None: + if ( + not stat.S_ISDIR(dir_stat.st_mode) + or + dir_stat.st_uid != os.getuid() + ): + raise PermissionError( + f'Unsafe Darwin runtime directory!\n' + f'path: {rt_dir}\n' + f'owner uid: {dir_stat.st_uid}\n' + f'mode: {stat.filemode(dir_stat.st_mode)}\n' + ) + if rt_dir != rt_root: + rt_dir.relative_to(rt_root) return rt_dir