diff --git a/CHANGES b/CHANGES index 928080202..99f09c13a 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,26 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Socket paths are measured before tmux sees them (#725) + +A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un` +— 107 bytes on Linux, 103 on macOS. {class}`~libtmux.Server` now measures the +path it is about to use and raises the new +{exc}`~libtmux.exc.SocketPathTooLong`, carrying the byte count, the limit, and +the path. Previously the object constructed fine and the overrun arrived at the +first tmux command as a passed-through `File name too long`, attributed to +whichever subcommand ran — `new-session`, usually — rather than to the socket. + +Both routes to a path are checked: a `socket_path` passed in, and the path a +`socket_name` resolves to under `$TMUX_TMPDIR`. The second is the one that +bites, since the length is inherited from the environment — a pytest +`tmp_path`, an XDG runtime dir, a nested worktree, a CI checkout under a long +workspace prefix — so the caller never typed the path that failed. The +workaround is a shorter socket directory: {func}`tempfile.mkdtemp` or a short +`$TMUX_TMPDIR`. See {ref}`socket_path_length` for the pytest case. + ### Fixes - {class}`~libtmux.Server` now reprs the socket path tmux resolves from diff --git a/docs/api/testing/pytest-plugin/usage.md b/docs/api/testing/pytest-plugin/usage.md index 9ae0fd49d..55280997e 100644 --- a/docs/api/testing/pytest-plugin/usage.md +++ b/docs/api/testing/pytest-plugin/usage.md @@ -112,6 +112,38 @@ True This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts. +(socket_path_length)= + +### Socket paths and the UNIX socket limit + +A tmux socket is a UNIX domain socket, so its path is capped by `sockaddr_un` +— 107 bytes on Linux, 103 on macOS. pytest's {fixture}`tmp_path` is nested +deep by design (`/tmp/pytest-of-/pytest-/`), so putting +a socket under it — directly, or by pointing `TMUX_TMPDIR` at it — can overrun +the limit on a long test name or a long temporary root. {class}`~libtmux.Server` +measures the path at construction and raises +{exc}`~libtmux.exc.SocketPathTooLong` with the byte count, rather than letting +tmux report `File name too long` at whichever command runs first: + +```python +>>> from libtmux import exc +>>> from libtmux.server import Server as TmuxServer +>>> deep_socket = "/tmp/" + "d" * 120 + "/sock" +>>> try: +... TmuxServer(socket_path=deep_socket) +... except exc.SocketPathTooLong as e: +... print(e.length) +130 +``` + +The fixtures in this plugin sidestep it: {fixture}`server +` and {fixture}`TestServer +` name their sockets with `socket_name`, which +tmux resolves under its own short socket directory. In your own tests, keep +`tmp_path` for files and reach for {func}`tempfile.mkdtemp` — which gives a +short `/tmp/` — when you need a socket path of your own, or point +`TMUX_TMPDIR` somewhere short. + (set_home)= ### Setting a temporary home directory diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index e1180c380..0033a8a36 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -46,13 +46,16 @@ without you arranging anything. That leaves the two variables that *are* yours to set, and most people set neither. `TMUX_TMPDIR` is tmux's own — the directory it keeps sockets -in. libtmux never reads it, but the tmux binary it shells out to does, so -it shapes which server a bare {class}`~libtmux.Server` lands on; pass -`socket_name` or `socket_path` when you would rather name the server -outright. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one variable libtmux -itself defines: an advanced override for the separator (default `␞`) it -uses internally to parse tmux's format output — you'd touch it only if -that character ever collided with your own data. +in. The tmux binary libtmux shells out to reads it, so it shapes which +server a bare {class}`~libtmux.Server` lands on; pass `socket_name` or +`socket_path` when you would rather name the server outright. libtmux +reads it only to know where the socket lands, which is also how it can +tell you that a deep `TMUX_TMPDIR` pushes the resolved path past what a +UNIX socket address holds — {exc}`~libtmux.exc.SocketPathTooLong`, see +{ref}`socket_path_length`. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one +variable libtmux itself defines: an advanced override for the separator +(default `␞`) it uses internally to parse tmux's format output — you'd +touch it only if that character ever collided with your own data. ## Format strings diff --git a/src/libtmux/_internal/env.py b/src/libtmux/_internal/env.py index f79ac0b2f..0d55fc8e0 100644 --- a/src/libtmux/_internal/env.py +++ b/src/libtmux/_internal/env.py @@ -34,10 +34,14 @@ import os import pathlib +import sys import typing as t from libtmux import exc +if t.TYPE_CHECKING: + from libtmux._internal.types import StrPath + TMUX: t.Final = "TMUX" """Environment variable tmux exports with ``socket_path,server_pid,session_id``.""" @@ -53,6 +57,20 @@ DEFAULT_SOCKET_NAME: t.Final = "default" """Socket name tmux uses when neither ``-L`` nor ``-S`` was given.""" +# ``sun_path`` in ``struct sockaddr_un`` is a fixed-size char array, and the +# stdlib publishes no constant for its size, so it is spelled out per platform. +# The size is part of each platform's frozen ABI: 104 bytes on the BSD-derived +# kernels (macOS, FreeBSD, OpenBSD, NetBSD), 108 on Linux and elsewhere. One +# byte of it is the NUL terminator. The test suite probes the running kernel to +# keep this honest, which reads better than bisecting for the limit at import +# time. +_SUN_PATH_SIZE: t.Final = ( + 104 if sys.platform.startswith(("darwin", "freebsd", "openbsd", "netbsd")) else 108 +) + +SOCKET_PATH_MAX_BYTES: t.Final = _SUN_PATH_SIZE - 1 +"""Bytes a tmux socket path may occupy on this platform.""" + def resolve_env(env: t.Mapping[str, str] | None = None) -> t.Mapping[str, str]: """Return *env*, defaulting to the live process environment. @@ -129,6 +147,64 @@ def resolve_socket_path( ) +def check_socket_path_length( + socket_path: StrPath, + *, + socket_name: str | None = None, +) -> None: + """Raise if *socket_path* is too long to be a UNIX socket address. + + A tmux socket is a UNIX domain socket, so its path has to fit in + :data:`SOCKET_PATH_MAX_BYTES` -- a filesystem that accepts the path says + nothing about whether a socket can be bound at it. Length is counted in + *bytes*, as the kernel counts it, so a non-ASCII path runs out sooner than + its character count suggests. + + Parameters + ---------- + socket_path : str or :class:`os.PathLike` + Path to measure. + socket_name : str, optional + Socket name *socket_path* was resolved from, when it was resolved + rather than passed in. Recorded on the exception so the message can say + the length was inherited from ``$TMUX_TMPDIR``. + + Raises + ------ + :exc:`~libtmux.exc.SocketPathTooLong` + When *socket_path* exceeds :data:`SOCKET_PATH_MAX_BYTES` bytes. + + Examples + -------- + >>> from libtmux._internal.env import ( + ... check_socket_path_length, + ... SOCKET_PATH_MAX_BYTES, + ... ) + >>> check_socket_path_length("/tmp/tmux-1000/default") + + >>> try: + ... check_socket_path_length("/tmp/" + "d" * 200 + "/sock") + ... except exc.SocketPathTooLong as e: + ... (e.length, e.limit == SOCKET_PATH_MAX_BYTES) + (210, True) + + A name that resolves somewhere too deep reports the name too: + + >>> deep = resolve_socket_path("dev", env={"TMUX_TMPDIR": "/tmp/" + "d" * 200}) + >>> try: + ... check_socket_path_length(deep, socket_name="dev") + ... except exc.SocketPathTooLong as e: + ... e.socket_name + 'dev' + """ + if len(os.fsencode(socket_path)) > SOCKET_PATH_MAX_BYTES: + raise exc.SocketPathTooLong( + socket_path, + SOCKET_PATH_MAX_BYTES, + socket_name=socket_name, + ) + + def socket_path_from_env(env: t.Mapping[str, str] | None = None) -> str: """Return the tmux socket path recorded in ``$TMUX``. diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102..aef4bd54a 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -7,9 +7,11 @@ from __future__ import annotations +import os import typing as t if t.TYPE_CHECKING: + from libtmux._internal.types import StrPath from libtmux.neo import ListExtraArgs @@ -151,6 +153,97 @@ def __init__( ) +class SocketPathTooLong(LibTmuxException): + """A tmux socket path is longer than a UNIX socket address can hold. + + ``sun_path`` in ``struct sockaddr_un`` is a fixed-size buffer, so a path + over the platform's limit can never be connected to, whatever the + filesystem allows. tmux reports it as a passed-through ``File name too + long`` at the first command, attributed to that subcommand rather than to + the socket; :class:`~libtmux.Server` raises this while the caller still has + the frame that built it. + + The overrun is usually inherited rather than typed: a deep pytest + ``tmp_path``, an XDG runtime dir, a nested worktree, a long + ``$TMUX_TMPDIR``. So the message carries the byte count and the limit -- + the numbers tmux does not give. + + Parameters + ---------- + socket_path : str or :class:`os.PathLike` + The path that does not fit. Measured in bytes, as the kernel does. + limit : int + Bytes available for a socket path on this platform. + *args : object + Forwarded to :class:`LibTmuxException`. + socket_name : str, optional + Set when the path was *resolved* from a socket name rather than passed + in, in which case the length came from ``$TMUX_TMPDIR``. + + Attributes + ---------- + socket_path : str or :class:`os.PathLike` + The path that does not fit. + length : int + Length of *socket_path* in bytes. + limit : int + Bytes available for a socket path on this platform. + socket_name : str or None + Socket name the path was resolved from, if any. + + Examples + -------- + >>> from libtmux import exc + >>> print(exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107)) + Socket path is 130 bytes, over the 107 byte limit: /tmp/ddd... + + A path nobody typed says where it came from: + + >>> print( + ... exc.SocketPathTooLong( + ... "/tmp/" + "d" * 120 + "/tmux-1000/dev", 107, socket_name="dev" + ... ) + ... ) + Socket path for socket_name='dev' is 139 bytes, over the 107 byte + limit: /tmp/ddd... + + The byte count and the limit are readable, for a caller that would rather + format its own message: + + >>> e = exc.SocketPathTooLong("/tmp/" + "d" * 120 + "/sock", 107) + >>> e.length, e.limit + (130, 107) + + It is part of the :exc:`LibTmuxException` hierarchy, so + ``except LibTmuxException`` catches it: + + >>> issubclass(exc.SocketPathTooLong, exc.LibTmuxException) + True + + .. versionadded:: 0.63 + """ + + def __init__( + self, + socket_path: StrPath, + limit: int, + *args: object, + socket_name: str | None = None, + ) -> None: + self.socket_path: StrPath = socket_path + self.length: int = len(os.fsencode(socket_path)) + self.limit: int = limit + self.socket_name: str | None = socket_name + subject = "Socket path" + if socket_name is not None: + subject += f" for socket_name={socket_name!r}" + super().__init__( + f"{subject} is {self.length} bytes, over the {limit} byte limit: " + f"{socket_path}", + *args, + ) + + class ObjectDoesNotExist(LibTmuxException): """A lookup expected one object and matched none. diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 864b1193a..9351d0b62 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,7 +16,11 @@ import warnings from libtmux import exc -from libtmux._internal.env import resolve_socket_path, socket_path_from_env +from libtmux._internal.env import ( + check_socket_path_length, + resolve_socket_path, + socket_path_from_env, +) from libtmux._internal.query_list import QueryList from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd @@ -99,6 +103,16 @@ class Server( When instantiated stores information on live, running tmux server. + A tmux socket is a UNIX domain socket, so its path is capped by + ``sockaddr_un`` -- 107 bytes on Linux, 103 on macOS. Both a ``socket_path`` + passed in and the path a ``socket_name`` resolves to under ``$TMUX_TMPDIR`` + are measured here, so an unusable socket is refused at construction rather + than surfacing later as a tmux ``File name too long`` blamed on whichever + command ran first. When the depth is not yours to choose -- a pytest + ``tmp_path``, a nested worktree, a CI checkout under a long workspace prefix + -- put the socket under :func:`tempfile.mkdtemp` or point ``$TMUX_TMPDIR`` + at something short. + Parameters ---------- socket_name : str, optional @@ -109,6 +123,12 @@ class Server( socket_name_factory : callable, optional tmux_bin : str or pathlib.Path, optional + Raises + ------ + :exc:`~libtmux.exc.SocketPathTooLong` + When the socket path -- given, or resolved from ``socket_name`` -- + cannot fit in a UNIX socket address. + Examples -------- >>> server @@ -133,6 +153,27 @@ class Server( ... # Do work with the session ... # Server will be killed automatically when exiting the context + A socket path that cannot fit in a UNIX socket address is refused, with the + byte count tmux would not have given: + + >>> from libtmux.server import Server as TmuxServer + >>> try: + ... TmuxServer(socket_path="/tmp/" + "d" * 120 + "/sock") + ... except exc.SocketPathTooLong as e: + ... print(e.length) + 130 + + The same goes for the path a short ``socket_name`` resolves to, when + ``$TMUX_TMPDIR`` is the long part: + + >>> with monkeypatch.context() as m: + ... m.setenv("TMUX_TMPDIR", "/tmp/" + "d" * 120) + ... try: + ... TmuxServer(socket_name="dev") + ... except exc.SocketPathTooLong as e: + ... print(e.socket_name) + dev + References ---------- .. [server_manual] CLIENTS AND SESSIONS. openbsd manpage for TMUX(1) @@ -184,11 +225,19 @@ def __init__( self._panes: list[PaneDict] = [] if socket_path is not None: + check_socket_path_length(socket_path) self.socket_path = socket_path - elif socket_name is not None: - self.socket_name = socket_name - elif socket_name_factory is not None: - self.socket_name = socket_name_factory() + else: + if socket_name is None and socket_name_factory is not None: + socket_name = socket_name_factory() + # Also covers the bare ``Server()``, whose socket path is inherited + # from ``$TMUX_TMPDIR`` in full. + check_socket_path_length( + resolve_socket_path(socket_name), + socket_name=socket_name, + ) + if socket_name is not None: + self.socket_name = socket_name if config_file: self.config_file = config_file diff --git a/tests/test_server.py b/tests/test_server.py index 8fc73f663..04f042416 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil +import socket import subprocess import time import typing as t @@ -15,6 +16,7 @@ from libtmux import exc from libtmux._internal.control_mode import ControlMode +from libtmux._internal.env import SOCKET_PATH_MAX_BYTES from libtmux.server import Server if t.TYPE_CHECKING: @@ -90,6 +92,96 @@ def test_repr_socket_name(monkeypatch: pytest.MonkeyPatch) -> None: assert repr(myserver) == "Server(socket_name=libtmux_test_repr)" +def _af_unix_path_fits(length: int) -> bool: + """Return True if a *length*-byte path fits in a UNIX socket address. + + CPython measures ``sun_path`` itself and refuses an oversized path before + any syscall, so this asks the live platform for its limit without creating a + socket file. A path that fits fails for an ordinary reason instead -- + nothing is listening at it. + """ + sock = socket.socket(socket.AF_UNIX) + try: + sock.connect("/" + "x" * (length - 1)) + except OSError as e: + return "too long" not in str(e) + finally: + sock.close() + return True + + +def test_socket_path_max_bytes_matches_platform() -> None: + """The compiled-in limit is the one this platform actually enforces. + + ``sun_path``'s size is not exposed by the stdlib, so libtmux spells it out + per platform. This keeps that number honest by probing. + """ + assert _af_unix_path_fits(SOCKET_PATH_MAX_BYTES) + assert not _af_unix_path_fits(SOCKET_PATH_MAX_BYTES + 1) + + +def test_socket_path_too_long_raises() -> None: + """An oversized ``socket_path`` is refused at construction. + + Regression for #725: the server constructed fine and the overrun surfaced + at the first tmux command, as a passed-through ``File name too long`` + attributed to that command rather than to the socket. + """ + socket_path = f"/tmp/{'d' * 200}/sock" + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + Server(socket_path=socket_path) + + assert excinfo.value.length == len(socket_path) + assert excinfo.value.limit == SOCKET_PATH_MAX_BYTES + assert excinfo.value.socket_name is None + assert str(excinfo.value) == ( + f"Socket path is {len(socket_path)} bytes, over the " + f"{SOCKET_PATH_MAX_BYTES} byte limit: {socket_path}" + ) + + +def test_socket_path_at_limit_is_accepted() -> None: + """A path that exactly fills the limit is usable, so it is accepted.""" + socket_path = "/tmp/" + "d" * (SOCKET_PATH_MAX_BYTES - len("/tmp/")) + assert len(socket_path) == SOCKET_PATH_MAX_BYTES + + myserver = Server(socket_path=socket_path) + + assert myserver.socket_path == socket_path + + +def test_socket_name_too_long_via_tmux_tmpdir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A short name resolving under a long ``$TMUX_TMPDIR`` is refused too. + + Regression for #725. This is the case that bites: the length is inherited + from the environment, so the caller never saw the path that failed -- hence + the resolved path and the name are both reported. + """ + monkeypatch.setenv("TMUX_TMPDIR", f"/tmp/{'d' * 200}") + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + Server(socket_name="dev") + + assert excinfo.value.socket_name == "dev" + assert str(excinfo.value.socket_path).endswith(f"/tmux-{os.geteuid()}/dev") + + +def test_default_socket_too_long_via_tmux_tmpdir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bare ``Server()`` inherits its whole path, so it is checked as well.""" + monkeypatch.setenv("TMUX_TMPDIR", f"/tmp/{'d' * 200}") + + with pytest.raises(exc.SocketPathTooLong) as excinfo: + Server() + + assert excinfo.value.socket_name is None + assert str(excinfo.value.socket_path).endswith("/default") + + def test_config(server: Server) -> None: """``-f`` file for tmux(1) configuration.""" myserver = Server(config_file="test")