Adds the files_mcp/ sibling package and core/path_guard.py which resolves arbitrary input paths and rejects any that escape the FILES_MCP_ROOT sandbox (handles .., ~, symlinks, NUL, empty). Adds tests/unit/test_path_guard.py covering accept, escape modes, symlink escape, and _init_root failure paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
85 lines
3.1 KiB
Python
85 lines
3.1 KiB
Python
# coding=utf-8
|
|
"""
|
|
@Time :2026/6/30
|
|
@Author :tao.chen
|
|
"""
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from files_mcp.core import path_guard
|
|
|
|
|
|
@pytest.fixture
|
|
def sandbox_root(tmp_path, monkeypatch):
|
|
"""Fresh tmp_path-based sandbox root for each test.
|
|
|
|
Pre-populates a regular file, a nested file inside a subdir, and a
|
|
symlink that points outside the sandbox (for the symlink-escape test).
|
|
"""
|
|
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path))
|
|
path_guard.set_root_for_testing(tmp_path.resolve())
|
|
(tmp_path / "inside.txt").write_text("hi", encoding="utf-8")
|
|
(tmp_path / "subdir").mkdir()
|
|
(tmp_path / "subdir" / "nested.txt").write_text("nested", encoding="utf-8")
|
|
(tmp_path / "escape_link").symlink_to("/etc/passwd")
|
|
return tmp_path.resolve()
|
|
|
|
|
|
class TestResolveAndCheck:
|
|
def test_accepts_inside_path(self, sandbox_root):
|
|
p = path_guard.resolve_and_check("inside.txt")
|
|
assert p == sandbox_root / "inside.txt"
|
|
|
|
def test_accepts_nested_path(self, sandbox_root):
|
|
p = path_guard.resolve_and_check("subdir/nested.txt")
|
|
assert p == sandbox_root / "subdir" / "nested.txt"
|
|
|
|
def test_accepts_dot_segments(self, sandbox_root):
|
|
p = path_guard.resolve_and_check("subdir/../inside.txt")
|
|
assert p == sandbox_root / "inside.txt"
|
|
|
|
def test_rejects_double_dot_escape(self, sandbox_root):
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
path_guard.resolve_and_check("../etc/passwd")
|
|
|
|
def test_rejects_absolute_outside_path(self, sandbox_root):
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
path_guard.resolve_and_check("/etc/passwd")
|
|
|
|
def test_rejects_mixed_escape(self, sandbox_root):
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
path_guard.resolve_and_check("subdir/../../etc/passwd")
|
|
|
|
def test_rejects_symlink_escape(self, sandbox_root):
|
|
# escape_link -> /etc/passwd; resolve() follows the link first.
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
path_guard.resolve_and_check("escape_link")
|
|
|
|
def test_rejects_empty(self, sandbox_root):
|
|
with pytest.raises(ValueError, match="path is empty"):
|
|
path_guard.resolve_and_check("")
|
|
|
|
def test_rejects_nul_byte(self, sandbox_root):
|
|
with pytest.raises(ValueError, match="NUL byte"):
|
|
path_guard.resolve_and_check("foo\x00bar")
|
|
|
|
|
|
class TestInitRoot:
|
|
def test_init_root_raises_if_unset(self, monkeypatch):
|
|
monkeypatch.delenv("FILES_MCP_ROOT", raising=False)
|
|
path_guard._ROOT = None
|
|
with pytest.raises(RuntimeError, match="FILES_MCP_ROOT"):
|
|
path_guard._init_root()
|
|
|
|
def test_init_root_raises_if_not_directory(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path / "missing"))
|
|
path_guard._ROOT = None
|
|
with pytest.raises(RuntimeError, match="not an existing directory"):
|
|
path_guard._init_root()
|
|
|
|
def test_init_root_is_idempotent(self, sandbox_root):
|
|
first = path_guard._ROOT
|
|
path_guard._init_root()
|
|
assert path_guard._ROOT == first
|