Files
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

160 lines
4.5 KiB
Python

"""Tests for filesystem path safety primitives."""
from pathlib import Path
import pytest
from mcp.shared.path_security import (
PathEscapeError,
contains_path_traversal,
is_absolute_path,
safe_join,
)
@pytest.mark.parametrize(
("value", "expected"),
[
# Safe: no traversal
("a/b/c", False),
("readme.txt", False),
("", False),
(".", False),
("./a/b", False),
# Safe: .. balanced by prior descent
("a/../b", False),
("a/b/../c", False),
("a/b/../../c", False),
# Unsafe: net escape
("..", True),
("../etc", True),
("../../etc/passwd", True),
("a/../../b", True),
("./../../etc", True),
# .. as substring, not component — safe
("1.0..2.0", False),
("foo..bar", False),
("..foo", False),
("foo..", False),
# Backslash separator
("..\\etc", True),
("a\\..\\..\\b", True),
("a\\b\\c", False),
# Mixed separators
("a/..\\..\\b", True),
],
)
def test_contains_path_traversal(value: str, expected: bool):
assert contains_path_traversal(value) is expected
@pytest.mark.parametrize(
("value", "expected"),
[
# Relative
("relative/path", False),
("file.txt", False),
("", False),
(".", False),
("..", False),
# POSIX absolute
("/", True),
("/etc/passwd", True),
("/a", True),
# Windows drive
("C:", True),
("C:\\Windows", True),
("c:/foo", True),
("Z:\\", True),
# Windows UNC / backslash-absolute
("\\\\server\\share", True),
("\\foo", True),
# Windows drive-relative — discards the join base when drives differ
("C:relative", True),
("x:y", True),
("a:debug", True),
# Not a drive: digit before colon
("1:foo", False),
# Colon not in position 1
("ab:c", False),
# Non-ASCII letter is not a drive letter
("Ω:namespace", False),
("é:foo", False),
],
)
def test_is_absolute_path(value: str, expected: bool):
assert is_absolute_path(value) is expected
def test_safe_join_simple(tmp_path: Path):
result = safe_join(tmp_path, "docs", "readme.txt")
assert result == tmp_path / "docs" / "readme.txt"
def test_safe_join_resolves_relative_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.chdir(tmp_path)
result = safe_join(".", "file.txt")
assert result == tmp_path / "file.txt"
def test_safe_join_rejects_dotdot_escape(tmp_path: Path):
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(tmp_path, "../../../etc/passwd")
def test_safe_join_rejects_balanced_then_escape(tmp_path: Path):
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(tmp_path, "a/../../etc")
def test_safe_join_allows_balanced_dotdot(tmp_path: Path):
result = safe_join(tmp_path, "a/../b")
assert result == tmp_path / "b"
def test_safe_join_rejects_absolute_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "/etc/passwd")
def test_safe_join_rejects_absolute_in_later_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "docs", "/etc/passwd")
def test_safe_join_rejects_windows_drive(tmp_path: Path):
with pytest.raises(PathEscapeError, match="is absolute"):
safe_join(tmp_path, "C:\\Windows\\System32")
def test_safe_join_rejects_null_byte(tmp_path: Path):
with pytest.raises(PathEscapeError, match="null byte"):
safe_join(tmp_path, "file\0.txt")
def test_safe_join_rejects_null_byte_in_later_part(tmp_path: Path):
with pytest.raises(PathEscapeError, match="null byte"):
safe_join(tmp_path, "docs", "file\0.txt")
def test_safe_join_rejects_symlink_escape(tmp_path: Path):
outside = tmp_path / "outside"
outside.mkdir()
sandbox = tmp_path / "sandbox"
sandbox.mkdir()
(sandbox / "escape").symlink_to(outside)
with pytest.raises(PathEscapeError, match="escapes base"):
safe_join(sandbox, "escape", "secret.txt")
def test_safe_join_base_equals_target(tmp_path: Path):
# Joining nothing (or ".") should return the base itself
assert safe_join(tmp_path) == tmp_path
assert safe_join(tmp_path, ".") == tmp_path
def test_path_escape_error_is_value_error():
with pytest.raises(ValueError):
safe_join("/tmp", "/etc")