"""Security tests — repository ownership check (CVE-2022-24765 equivalent). Verifies that find_repo_root() raises UntrustedRepositoryError when the .muse/ directory is owned by a different UID, and that escape hatches (MUSE_SAFE_DIRS env var, ~/.muse/config.toml safe_dirs) work correctly. """ from __future__ import annotations import os import pathlib from unittest.mock import patch import pytest from muse.core.errors import UntrustedRepositoryError from muse.core.repo import find_repo_root, _check_repo_ownership from muse.core.paths import muse_dir # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Create a minimal fake repo at tmp_path with a real .muse/ dir.""" dot_muse = muse_dir(tmp_path) dot_muse.mkdir(parents=True) return tmp_path # --------------------------------------------------------------------------- # Control 1: ownership check # --------------------------------------------------------------------------- def test_trusted_own_repo(tmp_path: pathlib.Path) -> None: """Current user owns .muse/ → find_repo_root() succeeds.""" root = _make_repo(tmp_path) # The tmp dir is created by pytest under the current user — ownership matches. with patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False): result = find_repo_root() assert result is not None assert result.resolve() == root.resolve() def test_untrusted_other_uid(tmp_path: pathlib.Path) -> None: """.muse/ owned by different UID → raises UntrustedRepositoryError.""" root = _make_repo(tmp_path) current_uid = os.getuid() other_uid = current_uid + 1 real_path_stat = pathlib.Path.stat def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result: result = real_path_stat(self, **kwargs) if self.resolve() == muse_dir(root).resolve(): return os.stat_result(( result.st_mode, result.st_ino, result.st_dev, result.st_nlink, other_uid, result.st_gid, result.st_size, result.st_atime, result.st_mtime, result.st_ctime, )) return result with ( patch.object(pathlib.Path, "stat", fake_path_stat), patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False), patch.dict(os.environ, {"MUSE_SAFE_DIRS": ""}, clear=False), ): with pytest.raises(UntrustedRepositoryError) as exc_info: find_repo_root() err = exc_info.value assert err.owner_uid == other_uid assert err.current_uid == current_uid assert str(root) in str(err) def test_root_bypasses_ownership(tmp_path: pathlib.Path) -> None: """uid==0 → no ownership check performed.""" root = _make_repo(tmp_path) # Simulate running as root. with ( patch("os.getuid", return_value=0), patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False), ): result = find_repo_root() assert result is not None def test_muse_safe_dirs_env(tmp_path: pathlib.Path) -> None: """MUSE_SAFE_DIRS containing the path → bypasses ownership check.""" root = _make_repo(tmp_path) current_uid = os.getuid() other_uid = current_uid + 1 real_path_stat = pathlib.Path.stat def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result: result = real_path_stat(self, **kwargs) if self.resolve() == muse_dir(root).resolve(): return os.stat_result(( result.st_mode, result.st_ino, result.st_dev, result.st_nlink, other_uid, result.st_gid, result.st_size, result.st_atime, result.st_mtime, result.st_ctime, )) return result with ( patch.object(pathlib.Path, "stat", fake_path_stat), patch.dict(os.environ, { "MUSE_REPO_ROOT": str(root), "MUSE_SAFE_DIRS": str(root.resolve()), }, clear=False), ): result = find_repo_root() assert result is not None def test_muse_safe_dirs_multi(tmp_path: pathlib.Path) -> None: """Multiple paths in MUSE_SAFE_DIRS (colon-separated) → correct path is trusted.""" root = _make_repo(tmp_path) current_uid = os.getuid() other_uid = current_uid + 1 real_path_stat = pathlib.Path.stat def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result: result = real_path_stat(self, **kwargs) if self.resolve() == muse_dir(root).resolve(): return os.stat_result(( result.st_mode, result.st_ino, result.st_dev, result.st_nlink, other_uid, result.st_gid, result.st_size, result.st_atime, result.st_mtime, result.st_ctime, )) return result multi_dirs = f"/tmp/something_else:{root.resolve()}:/tmp/another" with ( patch.object(pathlib.Path, "stat", fake_path_stat), patch.dict(os.environ, { "MUSE_REPO_ROOT": str(root), "MUSE_SAFE_DIRS": multi_dirs, }, clear=False), ): result = find_repo_root() assert result is not None def test_trust_add_cli(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """muse trust add /tmp/myrepo → appears in muse trust list output.""" import subprocess import sys fake_config = tmp_path / "config.toml" monkeypatch.setattr( "muse.cli.config._GLOBAL_CONFIG_FILE", fake_config, ) from muse.cli.config import add_global_safe_dir, get_global_safe_dirs add_global_safe_dir("/tmp/myrepo") dirs = get_global_safe_dirs() assert "/tmp/myrepo" in dirs def test_trust_remove_cli(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """add then remove → no longer in list.""" fake_config = tmp_path / "config.toml" monkeypatch.setattr( "muse.cli.config._GLOBAL_CONFIG_FILE", fake_config, ) from muse.cli.config import add_global_safe_dir, remove_global_safe_dir, get_global_safe_dirs add_global_safe_dir("/tmp/testrepo") assert "/tmp/testrepo" in get_global_safe_dirs() remove_global_safe_dir("/tmp/testrepo") assert "/tmp/testrepo" not in get_global_safe_dirs() def test_trust_list_empty(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """No trusted dirs → get_global_safe_dirs() returns [].""" fake_config = tmp_path / "config.toml" monkeypatch.setattr( "muse.cli.config._GLOBAL_CONFIG_FILE", fake_config, ) from muse.cli.config import get_global_safe_dirs dirs = get_global_safe_dirs() assert dirs == [] def test_global_config_safe_dirs(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """safe_dirs in ~/.muse/config.toml → bypasses ownership check.""" root = _make_repo(tmp_path) current_uid = os.getuid() other_uid = current_uid + 1 fake_config = tmp_path / "global_config.toml" monkeypatch.setattr( "muse.cli.config._GLOBAL_CONFIG_FILE", fake_config, ) # Write a trust entry for the root. from muse.cli.config import add_global_safe_dir add_global_safe_dir(str(root.resolve())) real_path_stat = pathlib.Path.stat def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result: result = real_path_stat(self, **kwargs) if self.resolve() == muse_dir(root).resolve(): return os.stat_result(( result.st_mode, result.st_ino, result.st_dev, result.st_nlink, other_uid, result.st_gid, result.st_size, result.st_atime, result.st_mtime, result.st_ctime, )) return result with ( patch.object(pathlib.Path, "stat", fake_path_stat), patch.dict(os.environ, { "MUSE_REPO_ROOT": str(root), "MUSE_SAFE_DIRS": "", }, clear=False), ): result = find_repo_root() assert result is not None def test_error_message_contains_fix_command(tmp_path: pathlib.Path) -> None: """Error message includes 'muse trust add'.""" root = _make_repo(tmp_path) current_uid = os.getuid() other_uid = current_uid + 1 real_path_stat = pathlib.Path.stat def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result: result = real_path_stat(self, **kwargs) if self.resolve() == muse_dir(root).resolve(): return os.stat_result(( result.st_mode, result.st_ino, result.st_dev, result.st_nlink, other_uid, result.st_gid, result.st_size, result.st_atime, result.st_mtime, result.st_ctime, )) return result with ( patch.object(pathlib.Path, "stat", fake_path_stat), patch.dict(os.environ, { "MUSE_REPO_ROOT": str(root), "MUSE_SAFE_DIRS": "", }, clear=False), ): with pytest.raises(UntrustedRepositoryError) as exc_info: find_repo_root() assert "muse trust add" in str(exc_info.value)