7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""Tests for PathValidator — security-sensitive path validation edge cases."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.security.path_validator import PathValidator
|
|
|
|
|
|
# ===========================================================================
|
|
# 1. validate_safe_path
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateSafePath:
|
|
"""Verify validate_safe_path input validation and traversal blocking."""
|
|
|
|
def test_non_string_input_rejected(self):
|
|
"""Non-string input raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_safe_path(123, "/tmp")
|
|
|
|
def test_none_input_rejected(self):
|
|
"""None input raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_safe_path(None, "/tmp")
|
|
|
|
def test_empty_string_rejected(self):
|
|
"""Empty string raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_safe_path("", "/tmp")
|
|
|
|
def test_null_bytes_rejected_in_safe_path(self, tmp_path):
|
|
"""Null bytes in validate_safe_path raise ValueError."""
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_safe_path("file\x00.txt", str(tmp_path))
|
|
|
|
def test_traversal_blocked(self, tmp_path):
|
|
"""Path traversal attempt via .. is blocked."""
|
|
with pytest.raises(ValueError):
|
|
PathValidator.validate_safe_path("../../etc/passwd", str(tmp_path))
|
|
|
|
def test_valid_relative_path(self, tmp_path):
|
|
"""Valid relative path returns resolved Path."""
|
|
result = PathValidator.validate_safe_path(
|
|
"subdir/file.txt", str(tmp_path)
|
|
)
|
|
assert result is not None
|
|
assert str(tmp_path) in str(result)
|
|
|
|
def test_extension_check_rejects_wrong_extension(self, tmp_path):
|
|
"""Wrong extension raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid file type"):
|
|
PathValidator.validate_safe_path(
|
|
"file.txt",
|
|
str(tmp_path),
|
|
required_extensions=(".json", ".yaml"),
|
|
)
|
|
|
|
def test_extension_check_accepts_correct_extension(self, tmp_path):
|
|
"""Correct extension passes validation."""
|
|
result = PathValidator.validate_safe_path(
|
|
"config.json",
|
|
str(tmp_path),
|
|
required_extensions=(".json", ".yaml"),
|
|
)
|
|
assert result.suffix == ".json"
|
|
|
|
def test_extension_case_sensitivity(self, tmp_path):
|
|
"""Extension check is case-sensitive — .JSON != .json."""
|
|
with pytest.raises(ValueError, match="Invalid file type"):
|
|
PathValidator.validate_safe_path(
|
|
"config.JSON",
|
|
str(tmp_path),
|
|
required_extensions=(".json",),
|
|
)
|
|
|
|
def test_whitespace_stripped(self, tmp_path):
|
|
"""Leading/trailing whitespace in input is stripped."""
|
|
result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path))
|
|
assert result is not None
|
|
assert "file.txt" in str(result)
|
|
|
|
|
|
# ===========================================================================
|
|
# 2. validate_local_filesystem_path — home dir expansion
|
|
# ===========================================================================
|
|
|
|
|
|
class TestHomeDirExpansion:
|
|
"""Verify ~ expansion edge cases."""
|
|
|
|
def test_tilde_slash_expands_to_home(self):
|
|
"""'~/' expands to home directory."""
|
|
home = str(Path.home())
|
|
result = PathValidator.validate_local_filesystem_path(
|
|
"~/documents",
|
|
restricted_dirs=[],
|
|
)
|
|
assert str(result).startswith(home)
|
|
|
|
def test_tilde_alone_expands_to_home(self):
|
|
"""'~' alone expands to home directory."""
|
|
home = Path.home().resolve()
|
|
result = PathValidator.validate_local_filesystem_path(
|
|
"~",
|
|
restricted_dirs=[],
|
|
)
|
|
assert result == home
|
|
|
|
def test_tilde_with_trailing_slashes(self):
|
|
"""'~/' with no relative part yields home directory."""
|
|
home = Path.home().resolve()
|
|
result = PathValidator.validate_local_filesystem_path(
|
|
"~/",
|
|
restricted_dirs=[],
|
|
)
|
|
assert result == home
|
|
|
|
def test_null_bytes_rejected(self):
|
|
"""Null bytes in path raise ValueError."""
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt")
|
|
|
|
def test_control_characters_rejected(self):
|
|
"""Control characters raise ValueError."""
|
|
with pytest.raises(ValueError, match="Control characters"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/file\x01name")
|
|
|
|
def test_path_traversal_blocked(self):
|
|
""".. in path raises ValueError."""
|
|
with pytest.raises(ValueError, match="traversal"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
|
|
|
|
def test_restricted_dir_blocked(self):
|
|
"""Access to /etc is blocked by default restrictions."""
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path("/etc/passwd")
|
|
|
|
def test_custom_restricted_dir(self, tmp_path):
|
|
"""Custom restricted dirs are enforced."""
|
|
restricted = tmp_path / "secret"
|
|
restricted.mkdir()
|
|
target = restricted / "file.txt"
|
|
target.touch()
|
|
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path(
|
|
str(target),
|
|
restricted_dirs=[restricted],
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# 3. sanitize_for_filesystem_ops — entirely uncovered
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSanitizeForFilesystemOps:
|
|
"""Verify sanitize_for_filesystem_ops validation."""
|
|
|
|
def test_relative_path_rejected(self):
|
|
"""Relative path raises ValueError."""
|
|
with pytest.raises(ValueError, match="must be absolute"):
|
|
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
|
|
|
|
def test_absolute_path_passes(self, tmp_path):
|
|
"""Absolute path is returned as a Path."""
|
|
result = PathValidator.sanitize_for_filesystem_ops(tmp_path)
|
|
assert isinstance(result, Path)
|
|
assert result.is_absolute()
|
|
|
|
def test_root_path(self):
|
|
"""Root path '/' passes sanitization."""
|
|
result = PathValidator.sanitize_for_filesystem_ops(Path("/"))
|
|
assert result == Path("/")
|
|
|
|
def test_deep_path_preserved(self, tmp_path):
|
|
"""Deep nested path structure is preserved."""
|
|
deep = tmp_path / "a" / "b" / "c"
|
|
result = PathValidator.sanitize_for_filesystem_ops(deep)
|
|
assert str(result).endswith("a/b/c")
|
|
|
|
|
|
# ===========================================================================
|
|
# 4. validate_model_path
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateModelPath:
|
|
"""Verify validate_model_path checks."""
|
|
|
|
def test_nonexistent_model_file(self, tmp_path):
|
|
"""Non-existent model file raises ValueError."""
|
|
with pytest.raises(ValueError, match="Model file not found"):
|
|
PathValidator.validate_model_path(
|
|
"model.bin", model_root=str(tmp_path)
|
|
)
|
|
|
|
def test_directory_not_file_rejected(self, tmp_path):
|
|
"""Directory path raises 'not a file' error."""
|
|
subdir = tmp_path / "model_dir"
|
|
subdir.mkdir()
|
|
with pytest.raises(ValueError, match="not a file"):
|
|
PathValidator.validate_model_path(
|
|
"model_dir", model_root=str(tmp_path)
|
|
)
|
|
|
|
def test_valid_model_file(self, tmp_path):
|
|
"""Valid model file returns resolved path."""
|
|
model_file = tmp_path / "model.gguf"
|
|
model_file.touch()
|
|
result = PathValidator.validate_model_path(
|
|
"model.gguf", model_root=str(tmp_path)
|
|
)
|
|
assert result == model_file.resolve()
|
|
|
|
def test_traversal_in_model_path(self, tmp_path):
|
|
"""Path traversal in model path is blocked."""
|
|
with pytest.raises(ValueError):
|
|
PathValidator.validate_model_path(
|
|
"../../../etc/passwd", model_root=str(tmp_path)
|
|
)
|
|
|
|
def test_model_root_created_if_missing(self, tmp_path):
|
|
"""Model root directory is created if it doesn't exist."""
|
|
new_root = tmp_path / "new_models"
|
|
# Will fail because file doesn't exist, but root should be created
|
|
try:
|
|
PathValidator.validate_model_path(
|
|
"test.bin", model_root=str(new_root)
|
|
)
|
|
except ValueError:
|
|
pass
|
|
assert new_root.exists()
|
|
|
|
|
|
# ===========================================================================
|
|
# 5. validate_config_path — restricted prefixes and null byte rejection
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateConfigPath:
|
|
"""Verify validate_config_path security checks."""
|
|
|
|
def test_non_string_rejected(self):
|
|
"""Non-string input raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid config path"):
|
|
PathValidator.validate_config_path(123)
|
|
|
|
def test_empty_string_rejected(self):
|
|
"""Empty string raises ValueError."""
|
|
with pytest.raises(ValueError, match="Invalid config path"):
|
|
PathValidator.validate_config_path("")
|
|
|
|
def test_null_bytes_rejected(self, tmp_path):
|
|
"""Null bytes in config paths raise ValueError."""
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_config_path(
|
|
"config\x00.json",
|
|
config_root=str(tmp_path),
|
|
)
|
|
|
|
def test_traversal_rejected(self):
|
|
""".. in config path raises ValueError."""
|
|
with pytest.raises(ValueError, match="traversal"):
|
|
PathValidator.validate_config_path(
|
|
"../etc/passwd", config_root="/tmp"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"restricted", ["etc/passwd", "proc/self", "sys/class", "dev/null"]
|
|
)
|
|
def test_restricted_prefixes_blocked(self, restricted):
|
|
"""System directory prefixes are blocked."""
|
|
with pytest.raises(ValueError, match="restricted system directory"):
|
|
PathValidator.validate_config_path(f"/{restricted}")
|
|
|
|
def test_restricted_prefix_exact_match(self):
|
|
"""Exact restricted directory name (no trailing path) is blocked."""
|
|
with pytest.raises(ValueError, match="restricted system directory"):
|
|
PathValidator.validate_config_path("/etc")
|
|
|
|
def test_invalid_extension_rejected(self, tmp_path):
|
|
"""Non-config extension is rejected."""
|
|
txt_file = tmp_path / "file.txt"
|
|
txt_file.touch()
|
|
with pytest.raises(ValueError, match="Invalid"):
|
|
PathValidator.validate_config_path(
|
|
"file.txt", config_root=str(tmp_path)
|
|
)
|
|
|
|
def test_valid_config_extensions(self, tmp_path):
|
|
"""All valid config extensions are accepted."""
|
|
for ext in (".json", ".yaml", ".yml", ".toml", ".ini", ".conf"):
|
|
config_file = tmp_path / f"config{ext}"
|
|
config_file.write_text("")
|
|
result = PathValidator.validate_config_path(
|
|
f"config{ext}",
|
|
config_root=str(tmp_path),
|
|
)
|
|
assert result is not None
|
|
|
|
def test_absolute_config_path_rejected_by_safe_join(self, tmp_path):
|
|
"""Absolute config paths fail safe_join validation (safe_join rejects absolute second args)."""
|
|
config_file = tmp_path / "app.json"
|
|
config_file.write_text("{}")
|
|
# safe_join("/", "/tmp/.../app.json") returns None because the path is absolute
|
|
with pytest.raises(ValueError, match="Invalid absolute path"):
|
|
PathValidator.validate_config_path(str(config_file))
|
|
|
|
def test_restricted_prefix_case_insensitive(self):
|
|
"""Restricted prefix check is case-insensitive."""
|
|
with pytest.raises(ValueError, match="restricted system directory"):
|
|
PathValidator.validate_config_path("/ETC/passwd")
|
|
|
|
|
|
# ===========================================================================
|
|
# 6. validate_data_path
|
|
# ===========================================================================
|
|
|
|
|
|
class TestValidateDataPath:
|
|
"""Verify validate_data_path basics."""
|
|
|
|
def test_valid_data_path(self, tmp_path):
|
|
"""Valid relative path returns resolved path."""
|
|
result = PathValidator.validate_data_path("data.db", str(tmp_path))
|
|
assert "data.db" in str(result)
|
|
|
|
def test_traversal_in_data_path(self, tmp_path):
|
|
"""Path traversal is blocked."""
|
|
with pytest.raises(ValueError):
|
|
PathValidator.validate_data_path("../../etc/passwd", str(tmp_path))
|