diff --git a/meshchatx.rsm b/meshchatx.rsm index b9ecc665..49a354bf 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/landlock_sandbox.py b/meshchatx/src/backend/landlock_sandbox.py index 355a6ed1..cfe38f0b 100644 --- a/meshchatx/src/backend/landlock_sandbox.py +++ b/meshchatx/src/backend/landlock_sandbox.py @@ -6,7 +6,6 @@ from __future__ import annotations import ctypes import ctypes.util -import errno import logging import os import site @@ -28,18 +27,39 @@ _LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9 _LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10 _LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11 _LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12 +_LANDLOCK_ACCESS_FS_REFER = 1 << 13 +_LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14 +_LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15 _LANDLOCK_CREATE_RULESET_VERSION = 1 << 0 _LANDLOCK_RULE_PATH_BENEATH = 1 _PR_SET_NO_NEW_PRIVS = 38 -_READ_ACCESS = ( +# ABI v1 filesystem rights. Newer rights are added only when the running ABI +# supports them, and only granted on paths that already need write or /dev. +_FS_ACCESS_ABI1 = ( + _LANDLOCK_ACCESS_FS_EXECUTE + | _LANDLOCK_ACCESS_FS_WRITE_FILE + | _LANDLOCK_ACCESS_FS_READ_FILE + | _LANDLOCK_ACCESS_FS_READ_DIR + | _LANDLOCK_ACCESS_FS_REMOVE_DIR + | _LANDLOCK_ACCESS_FS_REMOVE_FILE + | _LANDLOCK_ACCESS_FS_MAKE_CHAR + | _LANDLOCK_ACCESS_FS_MAKE_DIR + | _LANDLOCK_ACCESS_FS_MAKE_REG + | _LANDLOCK_ACCESS_FS_MAKE_SOCK + | _LANDLOCK_ACCESS_FS_MAKE_FIFO + | _LANDLOCK_ACCESS_FS_MAKE_BLOCK + | _LANDLOCK_ACCESS_FS_MAKE_SYM +) + +_READ_ACCESS_BASE = ( _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_READ_DIR | _LANDLOCK_ACCESS_FS_EXECUTE ) -_RW_ACCESS = _READ_ACCESS | ( +_RW_ACCESS_BASE = _READ_ACCESS_BASE | ( _LANDLOCK_ACCESS_FS_WRITE_FILE | _LANDLOCK_ACCESS_FS_REMOVE_DIR | _LANDLOCK_ACCESS_FS_REMOVE_FILE @@ -74,6 +94,7 @@ class _LandlockPathBeneathAttr(ctypes.Structure): ("parent_fd", ctypes.c_int32), ] _pack_ = 1 + _layout_ = "ms" def _parse_kernel_version(release: str) -> tuple[int, int, int]: @@ -118,6 +139,7 @@ def _landlock_env_override() -> bool | None: _landlock_support_cached: bool | None = None +_landlock_abi_cached: int | None = None def _syscall_numbers(): @@ -155,19 +177,74 @@ def _syscall(libc, nr: int, *args): return rc -def _probe_landlock_create_ruleset() -> bool: +def _handled_access_fs_for_abi(abi: int) -> int: + """Return handled FS rights for a best-effort sandbox on this ABI. + + Intentionally omits network port rules and IPC scoping so mesh traffic, + Unix sockets, and signals keep working. Omits RESOLVE_UNIX for the same + reason. Rights we do handle are also granted on RW roots (including /dev). + """ + if abi < 1: + return 0 + handled = _FS_ACCESS_ABI1 + if abi >= 2: + handled |= _LANDLOCK_ACCESS_FS_REFER + if abi >= 3: + handled |= _LANDLOCK_ACCESS_FS_TRUNCATE + if abi >= 5: + handled |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return handled + + +def _ruleset_attr_size(abi: int) -> int: + """Bytes of landlock_ruleset_attr the running ABI understands.""" + if abi >= 6: + return ctypes.sizeof(_LandlockRulesetAttr) + if abi >= 4: + return ctypes.sizeof(ctypes.c_uint64) * 2 + return ctypes.sizeof(ctypes.c_uint64) + + +def _read_access_for_handled(handled: int) -> int: + return _READ_ACCESS_BASE & handled + + +def _rw_access_for_handled(handled: int) -> int: + access = _RW_ACCESS_BASE + if handled & _LANDLOCK_ACCESS_FS_REFER: + access |= _LANDLOCK_ACCESS_FS_REFER + if handled & _LANDLOCK_ACCESS_FS_TRUNCATE: + access |= _LANDLOCK_ACCESS_FS_TRUNCATE + if handled & _LANDLOCK_ACCESS_FS_IOCTL_DEV: + access |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return access & handled + + +def _probe_landlock_abi() -> int: + """Return the Landlock ABI version, or 0 when unavailable.""" + global _landlock_abi_cached + if _landlock_abi_cached is not None: + return _landlock_abi_cached libc = _libc() nums = _syscall_numbers() if libc is None or nums is None: - return False + _landlock_abi_cached = 0 + return 0 create_nr, _, _ = nums try: - abi = _syscall(libc, create_nr, 0, 0, _LANDLOCK_CREATE_RULESET_VERSION) - except OSError as exc: - if exc.errno in (errno.ENOSYS, errno.EOPNOTSUPP): - return False - return False - return abi >= 1 + abi = int(_syscall(libc, create_nr, 0, 0, _LANDLOCK_CREATE_RULESET_VERSION)) + except OSError: + _landlock_abi_cached = 0 + return 0 + if abi < 1: + _landlock_abi_cached = 0 + return 0 + _landlock_abi_cached = abi + return abi + + +def _probe_landlock_create_ruleset() -> bool: + return _probe_landlock_abi() >= 1 def _is_android() -> bool: @@ -179,6 +256,15 @@ def _is_android() -> bool: return hasattr(sys, "getandroidapilevel") +def landlock_abi_version() -> int: + """Return the probed Landlock ABI version, or 0 if unsupported.""" + if sys.platform != "linux" or _is_android(): + return 0 + if not _kernel_version_meets_minimum(): + return 0 + return _probe_landlock_abi() + + def landlock_kernel_supported() -> bool: global _landlock_support_cached if _landlock_support_cached is not None: @@ -296,20 +382,34 @@ def _collect_rw_roots( return paths +def _file_access_from_dir_access(access: int, handled: int) -> int: + """Map a directory access mask to rights valid on a non-directory path.""" + file_bits = _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_WRITE_FILE + if access & _LANDLOCK_ACCESS_FS_EXECUTE: + file_bits |= _LANDLOCK_ACCESS_FS_EXECUTE + if access & _LANDLOCK_ACCESS_FS_TRUNCATE: + file_bits |= _LANDLOCK_ACCESS_FS_TRUNCATE + if access & _LANDLOCK_ACCESS_FS_IOCTL_DEV: + file_bits |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return file_bits & access & handled + + def _add_path_beneath_rule( libc, add_rule_nr: int, ruleset_fd: int, path: str, access: int, + handled: int, ) -> None: if not path or not os.path.exists(path): return - effective_access = access if not os.path.isdir(path): - effective_access = ( - _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_WRITE_FILE - ) + effective_access = _file_access_from_dir_access(access, handled) + else: + effective_access = access & handled + if effective_access == 0: + return open_flags = os.O_PATH | os.O_CLOEXEC | os.O_RDONLY try: fd = os.open(path, open_flags) @@ -353,13 +453,21 @@ def apply_landlock_sandbox( logger.warning("Landlock disabled: %s", exc) return False - attr = _LandlockRulesetAttr(handled_access_fs=_RW_ACCESS) + abi = _probe_landlock_abi() + if abi < 1: + logger.warning("Landlock disabled: ABI probe failed") + return False + + handled = _handled_access_fs_for_abi(abi) + read_access = _read_access_for_handled(handled) + rw_access = _rw_access_for_handled(handled) + attr = _LandlockRulesetAttr(handled_access_fs=handled) try: ruleset_fd = _syscall( libc, create_nr, ctypes.byref(attr), - ctypes.sizeof(attr), + _ruleset_attr_size(abi), 0, ) except OSError as exc: @@ -368,13 +476,17 @@ def apply_landlock_sandbox( try: for root in _collect_read_roots(): - _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _READ_ACCESS) + _add_path_beneath_rule( + libc, add_rule_nr, ruleset_fd, root, read_access, handled + ) rw_roots = _collect_rw_roots(storage_dir, reticulum_config_dir, log_dir) public_existing = _existing_dir(public_dir) if public_existing and public_existing not in rw_roots: rw_roots.append(public_existing) for root in rw_roots: - _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _RW_ACCESS) + _add_path_beneath_rule( + libc, add_rule_nr, ruleset_fd, root, rw_access, handled + ) _syscall(libc, restrict_nr, ruleset_fd, 0) except OSError as exc: logger.warning("Landlock disabled while adding rules: %s", exc) @@ -390,7 +502,10 @@ def apply_landlock_sandbox( pass if landlock_auto_enabled(): - logger.info("Landlock filesystem sandbox enabled (auto-detected on Linux)") + logger.info( + "Landlock filesystem sandbox enabled (auto-detected on Linux, ABI %s)", + abi, + ) else: - logger.info("Landlock filesystem sandbox enabled") + logger.info("Landlock filesystem sandbox enabled (ABI %s)", abi) return True diff --git a/tests/backend/test_landlock_sandbox.py b/tests/backend/test_landlock_sandbox.py index 85887a72..63ef1e94 100644 --- a/tests/backend/test_landlock_sandbox.py +++ b/tests/backend/test_landlock_sandbox.py @@ -107,3 +107,116 @@ def test_collect_read_roots_includes_interpreter_prefix(): assert any( prefix == root or prefix.startswith(root.rstrip("/") + "/") for root in roots ), f"prefix {prefix!r} not covered by {roots!r}" + + +def test_handled_access_fs_for_abi_gates_new_rights(): + abi1 = ll._handled_access_fs_for_abi(1) + assert abi1 & ll._LANDLOCK_ACCESS_FS_REFER == 0 + assert abi1 & ll._LANDLOCK_ACCESS_FS_TRUNCATE == 0 + assert abi1 & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV == 0 + assert abi1 & ll._LANDLOCK_ACCESS_FS_WRITE_FILE + + abi2 = ll._handled_access_fs_for_abi(2) + assert abi2 & ll._LANDLOCK_ACCESS_FS_REFER + assert abi2 & ll._LANDLOCK_ACCESS_FS_TRUNCATE == 0 + + abi3 = ll._handled_access_fs_for_abi(3) + assert abi3 & ll._LANDLOCK_ACCESS_FS_REFER + assert abi3 & ll._LANDLOCK_ACCESS_FS_TRUNCATE + assert abi3 & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV == 0 + + abi5 = ll._handled_access_fs_for_abi(5) + assert abi5 & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV + # Network and UNIX-resolve rights stay unhandled on purpose. + assert abi5 == ll._handled_access_fs_for_abi(10) + + +def test_rw_access_grants_new_rights_when_handled(): + handled = ll._handled_access_fs_for_abi(5) + read_access = ll._read_access_for_handled(handled) + rw_access = ll._rw_access_for_handled(handled) + assert read_access & ll._LANDLOCK_ACCESS_FS_TRUNCATE == 0 + assert read_access & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV == 0 + assert read_access & ll._LANDLOCK_ACCESS_FS_REFER == 0 + assert rw_access & ll._LANDLOCK_ACCESS_FS_TRUNCATE + assert rw_access & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV + assert rw_access & ll._LANDLOCK_ACCESS_FS_REFER + + +def test_ruleset_attr_size_matches_abi(): + assert ll._ruleset_attr_size(1) == 8 + assert ll._ruleset_attr_size(3) == 8 + assert ll._ruleset_attr_size(4) == 16 + assert ll._ruleset_attr_size(5) == 16 + assert ll._ruleset_attr_size(6) == 24 + + +def test_file_access_includes_truncate_with_write(): + handled = ll._handled_access_fs_for_abi(5) + rw = ll._rw_access_for_handled(handled) + file_access = ll._file_access_from_dir_access(rw, handled) + assert file_access & ll._LANDLOCK_ACCESS_FS_WRITE_FILE + assert file_access & ll._LANDLOCK_ACCESS_FS_TRUNCATE + assert file_access & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV + + +@pytest.mark.skipif(sys.platform != "linux", reason="Landlock probe requires Linux") +def test_landlock_abi_version_on_linux(): + ll._landlock_abi_cached = None + ll._landlock_support_cached = None + abi = ll.landlock_abi_version() + assert isinstance(abi, int) + assert abi >= 0 + if ll.landlock_kernel_supported(): + assert abi >= 1 + + +@pytest.mark.skipif( + sys.platform != "linux" or not ll.landlock_kernel_supported(), + reason="Landlock apply requires a supported Linux kernel", +) +def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path): + """Apply sandbox in a subprocess and confirm RW + truncate still work.""" + import subprocess + import textwrap + from pathlib import Path + + storage = tmp_path / "storage" + storage.mkdir() + script = textwrap.dedent( + f""" + import os + import sys + from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox + + storage = {str(storage)!r} + os.environ["MESHCHAT_LANDLOCK"] = "1" + ok = apply_landlock_sandbox(storage_dir=storage, log_dir=storage) + if not ok: + print("APPLY_FAILED") + sys.exit(2) + path = os.path.join(storage, "landlock-abi-check.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("hello") + with open(path, "w", encoding="utf-8") as handle: + handle.write("truncated") + with open(path, encoding="utf-8") as handle: + data = handle.read() + if data != "truncated": + print("TRUNCATE_FAILED", repr(data)) + sys.exit(3) + print("OK") + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=str(Path(__file__).resolve().parents[2]), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if "APPLY_FAILED" in result.stdout: + pytest.skip("Landlock could not be applied in this environment") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "OK" in result.stdout diff --git a/vendor/lxmfy/lxmfy/landlock_sandbox.py b/vendor/lxmfy/lxmfy/landlock_sandbox.py index 1082e3fa..60d0fb04 100644 --- a/vendor/lxmfy/lxmfy/landlock_sandbox.py +++ b/vendor/lxmfy/lxmfy/landlock_sandbox.py @@ -26,18 +26,39 @@ _LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9 _LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10 _LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11 _LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12 +_LANDLOCK_ACCESS_FS_REFER = 1 << 13 +_LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14 +_LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15 _LANDLOCK_CREATE_RULESET_VERSION = 1 << 0 _LANDLOCK_RULE_PATH_BENEATH = 1 _PR_SET_NO_NEW_PRIVS = 38 -_READ_ACCESS = ( +# ABI v1 filesystem rights. Newer rights are added only when the running ABI +# supports them, and only granted on paths that already need write or /dev. +_FS_ACCESS_ABI1 = ( + _LANDLOCK_ACCESS_FS_EXECUTE + | _LANDLOCK_ACCESS_FS_WRITE_FILE + | _LANDLOCK_ACCESS_FS_READ_FILE + | _LANDLOCK_ACCESS_FS_READ_DIR + | _LANDLOCK_ACCESS_FS_REMOVE_DIR + | _LANDLOCK_ACCESS_FS_REMOVE_FILE + | _LANDLOCK_ACCESS_FS_MAKE_CHAR + | _LANDLOCK_ACCESS_FS_MAKE_DIR + | _LANDLOCK_ACCESS_FS_MAKE_REG + | _LANDLOCK_ACCESS_FS_MAKE_SOCK + | _LANDLOCK_ACCESS_FS_MAKE_FIFO + | _LANDLOCK_ACCESS_FS_MAKE_BLOCK + | _LANDLOCK_ACCESS_FS_MAKE_SYM +) + +_READ_ACCESS_BASE = ( _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_READ_DIR | _LANDLOCK_ACCESS_FS_EXECUTE ) -_RW_ACCESS = _READ_ACCESS | ( +_RW_ACCESS_BASE = _READ_ACCESS_BASE | ( _LANDLOCK_ACCESS_FS_WRITE_FILE | _LANDLOCK_ACCESS_FS_REMOVE_DIR | _LANDLOCK_ACCESS_FS_REMOVE_FILE @@ -72,6 +93,7 @@ class _LandlockPathBeneathAttr(ctypes.Structure): ("parent_fd", ctypes.c_int32), ] _pack_ = 1 + _layout_ = "ms" def _parse_kernel_version(release: str) -> tuple[int, int, int]: @@ -116,6 +138,7 @@ def _landlock_env_override() -> bool | None: _landlock_support_cached: bool | None = None +_landlock_abi_cached: int | None = None def _syscall_numbers(): @@ -143,19 +166,83 @@ def _syscall(libc, nr: int, *args): return rc -def _probe_landlock_create_ruleset() -> bool: +def _handled_access_fs_for_abi(abi: int) -> int: + """Return handled FS rights for a best-effort sandbox on this ABI. + + Intentionally omits network port rules and IPC scoping so mesh traffic, + Unix sockets, and signals keep working. Omits RESOLVE_UNIX for the same + reason. Rights we do handle are also granted on RW roots (including /dev). + """ + if abi < 1: + return 0 + handled = _FS_ACCESS_ABI1 + if abi >= 2: + handled |= _LANDLOCK_ACCESS_FS_REFER + if abi >= 3: + handled |= _LANDLOCK_ACCESS_FS_TRUNCATE + if abi >= 5: + handled |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return handled + + +def _ruleset_attr_size(abi: int) -> int: + """Bytes of landlock_ruleset_attr the running ABI understands.""" + if abi >= 6: + return ctypes.sizeof(_LandlockRulesetAttr) + if abi >= 4: + return ctypes.sizeof(ctypes.c_uint64) * 2 + return ctypes.sizeof(ctypes.c_uint64) + + +def _read_access_for_handled(handled: int) -> int: + return _READ_ACCESS_BASE & handled + + +def _rw_access_for_handled(handled: int) -> int: + access = _RW_ACCESS_BASE + if handled & _LANDLOCK_ACCESS_FS_REFER: + access |= _LANDLOCK_ACCESS_FS_REFER + if handled & _LANDLOCK_ACCESS_FS_TRUNCATE: + access |= _LANDLOCK_ACCESS_FS_TRUNCATE + if handled & _LANDLOCK_ACCESS_FS_IOCTL_DEV: + access |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return access & handled + + +def _probe_landlock_abi() -> int: + """Return the Landlock ABI version, or 0 when unavailable.""" + global _landlock_abi_cached + if _landlock_abi_cached is not None: + return _landlock_abi_cached libc = _libc() nums = _syscall_numbers() if libc is None or nums is None: - return False + _landlock_abi_cached = 0 + return 0 create_nr, _, _ = nums try: - abi = _syscall(libc, create_nr, 0, 0, _LANDLOCK_CREATE_RULESET_VERSION) - except OSError as exc: - if exc.errno in (errno.ENOSYS, errno.EOPNOTSUPP): - return False - return False - return abi >= 1 + abi = int(_syscall(libc, create_nr, 0, 0, _LANDLOCK_CREATE_RULESET_VERSION)) + except OSError: + _landlock_abi_cached = 0 + return 0 + if abi < 1: + _landlock_abi_cached = 0 + return 0 + _landlock_abi_cached = abi + return abi + + +def _probe_landlock_create_ruleset() -> bool: + return _probe_landlock_abi() >= 1 + + +def landlock_abi_version() -> int: + """Return the probed Landlock ABI version, or 0 if unsupported.""" + if sys.platform != "linux": + return 0 + if not _kernel_version_meets_minimum(): + return 0 + return _probe_landlock_abi() def landlock_kernel_supported() -> bool: @@ -274,20 +361,34 @@ def _collect_rw_roots( return paths +def _file_access_from_dir_access(access: int, handled: int) -> int: + """Map a directory access mask to rights valid on a non-directory path.""" + file_bits = _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_WRITE_FILE + if access & _LANDLOCK_ACCESS_FS_EXECUTE: + file_bits |= _LANDLOCK_ACCESS_FS_EXECUTE + if access & _LANDLOCK_ACCESS_FS_TRUNCATE: + file_bits |= _LANDLOCK_ACCESS_FS_TRUNCATE + if access & _LANDLOCK_ACCESS_FS_IOCTL_DEV: + file_bits |= _LANDLOCK_ACCESS_FS_IOCTL_DEV + return file_bits & access & handled + + def _add_path_beneath_rule( libc, add_rule_nr: int, ruleset_fd: int, path: str, access: int, + handled: int, ) -> None: if not path or not os.path.exists(path): return - effective_access = access if not os.path.isdir(path): - effective_access = ( - _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_WRITE_FILE - ) + effective_access = _file_access_from_dir_access(access, handled) + else: + effective_access = access & handled + if effective_access == 0: + return open_flags = os.O_PATH | os.O_CLOEXEC | os.O_RDONLY try: fd = os.open(path, open_flags) @@ -335,13 +436,21 @@ def apply_landlock_sandbox( logger.warning("Landlock disabled: %s", exc) return False - attr = _LandlockRulesetAttr(handled_access_fs=_RW_ACCESS) + abi = _probe_landlock_abi() + if abi < 1: + logger.warning("Landlock disabled: ABI probe failed") + return False + + handled = _handled_access_fs_for_abi(abi) + read_access = _read_access_for_handled(handled) + rw_access = _rw_access_for_handled(handled) + attr = _LandlockRulesetAttr(handled_access_fs=handled) try: ruleset_fd = _syscall( libc, create_nr, ctypes.byref(attr), - ctypes.sizeof(attr), + _ruleset_attr_size(abi), 0, ) except OSError as exc: @@ -350,7 +459,9 @@ def apply_landlock_sandbox( try: for root in _collect_read_roots(extra_read_paths): - _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _READ_ACCESS) + _add_path_beneath_rule( + libc, add_rule_nr, ruleset_fd, root, read_access, handled + ) for root in _collect_rw_roots( storage_dir, reticulum_config_dir, @@ -359,7 +470,9 @@ def apply_landlock_sandbox( log_dir, temp_only=temp_only, ): - _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _RW_ACCESS) + _add_path_beneath_rule( + libc, add_rule_nr, ruleset_fd, root, rw_access, handled + ) _syscall(libc, restrict_nr, ruleset_fd, 0) except OSError as exc: logger.warning("Landlock disabled while adding rules: %s", exc) @@ -375,9 +488,12 @@ def apply_landlock_sandbox( pass if landlock_auto_enabled(config_enabled): - logger.info("Landlock filesystem sandbox enabled (auto-detected on Linux)") + logger.info( + "Landlock filesystem sandbox enabled (auto-detected on Linux, ABI %s)", + abi, + ) else: - logger.info("Landlock filesystem sandbox enabled") + logger.info("Landlock filesystem sandbox enabled (ABI %s)", abi) return True @@ -385,7 +501,7 @@ def landlock_status_dict( *, active: bool = False, config_enabled: bool = True, -) -> dict[str, bool]: +) -> dict[str, bool | int]: """Return a dict describing Landlock availability and state.""" return { "landlock_kernel_supported": landlock_kernel_supported(), @@ -393,4 +509,5 @@ def landlock_status_dict( "landlock_auto_enabled": landlock_auto_enabled(config_enabled), "landlock_disabled_by_env": landlock_disabled_by_env(), "landlock_active": active, + "landlock_abi_version": landlock_abi_version(), } diff --git a/vendor/lxmfy/tests/test_landlock_sandbox.py b/vendor/lxmfy/tests/test_landlock_sandbox.py index c4f4625f..e00b8793 100644 --- a/vendor/lxmfy/tests/test_landlock_sandbox.py +++ b/vendor/lxmfy/tests/test_landlock_sandbox.py @@ -95,3 +95,33 @@ def test_landlock_status_dict(): assert status["landlock_active"] is True assert "landlock_kernel_supported" in status assert "landlock_requested" in status + assert "landlock_abi_version" in status + + +def test_handled_access_fs_for_abi_gates_new_rights(): + abi1 = ll._handled_access_fs_for_abi(1) + assert abi1 & ll._LANDLOCK_ACCESS_FS_REFER == 0 + assert abi1 & ll._LANDLOCK_ACCESS_FS_TRUNCATE == 0 + assert abi1 & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV == 0 + + abi5 = ll._handled_access_fs_for_abi(5) + assert abi5 & ll._LANDLOCK_ACCESS_FS_REFER + assert abi5 & ll._LANDLOCK_ACCESS_FS_TRUNCATE + assert abi5 & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV + assert abi5 == ll._handled_access_fs_for_abi(10) + + +def test_rw_access_grants_new_rights_when_handled(): + handled = ll._handled_access_fs_for_abi(5) + rw_access = ll._rw_access_for_handled(handled) + read_access = ll._read_access_for_handled(handled) + assert read_access & ll._LANDLOCK_ACCESS_FS_TRUNCATE == 0 + assert rw_access & ll._LANDLOCK_ACCESS_FS_TRUNCATE + assert rw_access & ll._LANDLOCK_ACCESS_FS_IOCTL_DEV + assert rw_access & ll._LANDLOCK_ACCESS_FS_REFER + + +def test_ruleset_attr_size_matches_abi(): + assert ll._ruleset_attr_size(1) == 8 + assert ll._ruleset_attr_size(4) == 16 + assert ll._ruleset_attr_size(6) == 24