Files
hermes-agent/tests/tools/test_file_operations.py
teknium1 3fb8938cd3 fix: search_files now reports error for non-existent paths instead of silent empty results
Previously, search_files would silently return 0 results when the
search path didn't exist (e.g., /root/.hermes/... when HOME is
/home/user). The path was passed to rg/grep/find which would fail
silently, and the empty stdout was parsed as 'no matches found'.

Changes:
- Add path existence check at the top of search() using test -e.
  Returns SearchResult with a clear error message when path doesn't exist.
- Add exit code 2 checks in _search_with_rg() and _search_with_grep()
  as secondary safety net for other error types (bad regex, permissions).
- Add 4 new tests covering: nonexistent path (content mode), nonexistent
  path (files mode), existing path proceeds normally, rg error exit code.

Tests: 37 → 41 in test_file_operations.py, full suite 2330 passed.
2026-03-08 16:47:20 -07:00

336 lines
12 KiB
Python

"""Tests for tools/file_operations.py — deny list, result dataclasses, helpers."""
import os
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from tools.file_operations import (
_is_write_denied,
WRITE_DENIED_PATHS,
WRITE_DENIED_PREFIXES,
ReadResult,
WriteResult,
PatchResult,
SearchResult,
SearchMatch,
LintResult,
ShellFileOperations,
BINARY_EXTENSIONS,
IMAGE_EXTENSIONS,
MAX_LINE_LENGTH,
)
# =========================================================================
# Write deny list
# =========================================================================
class TestIsWriteDenied:
def test_ssh_authorized_keys_denied(self):
path = os.path.join(str(Path.home()), ".ssh", "authorized_keys")
assert _is_write_denied(path) is True
def test_ssh_id_rsa_denied(self):
path = os.path.join(str(Path.home()), ".ssh", "id_rsa")
assert _is_write_denied(path) is True
def test_netrc_denied(self):
path = os.path.join(str(Path.home()), ".netrc")
assert _is_write_denied(path) is True
def test_aws_prefix_denied(self):
path = os.path.join(str(Path.home()), ".aws", "credentials")
assert _is_write_denied(path) is True
def test_kube_prefix_denied(self):
path = os.path.join(str(Path.home()), ".kube", "config")
assert _is_write_denied(path) is True
def test_normal_file_allowed(self, tmp_path):
path = str(tmp_path / "safe_file.txt")
assert _is_write_denied(path) is False
def test_project_file_allowed(self):
assert _is_write_denied("/tmp/project/main.py") is False
def test_tilde_expansion(self):
assert _is_write_denied("~/.ssh/authorized_keys") is True
# =========================================================================
# Result dataclasses
# =========================================================================
class TestReadResult:
def test_to_dict_omits_defaults(self):
r = ReadResult()
d = r.to_dict()
assert "error" not in d # None omitted
assert "similar_files" not in d # empty list omitted
def test_to_dict_preserves_empty_content(self):
"""Empty file should still have content key in the dict."""
r = ReadResult(content="", total_lines=0, file_size=0)
d = r.to_dict()
assert "content" in d
assert d["content"] == ""
assert d["total_lines"] == 0
assert d["file_size"] == 0
def test_to_dict_includes_values(self):
r = ReadResult(content="hello", total_lines=10, file_size=50, truncated=True)
d = r.to_dict()
assert d["content"] == "hello"
assert d["total_lines"] == 10
assert d["truncated"] is True
def test_binary_fields(self):
r = ReadResult(is_binary=True, is_image=True, mime_type="image/png")
d = r.to_dict()
assert d["is_binary"] is True
assert d["is_image"] is True
assert d["mime_type"] == "image/png"
class TestWriteResult:
def test_to_dict_omits_none(self):
r = WriteResult(bytes_written=100)
d = r.to_dict()
assert d["bytes_written"] == 100
assert "error" not in d
assert "warning" not in d
def test_to_dict_includes_error(self):
r = WriteResult(error="Permission denied")
d = r.to_dict()
assert d["error"] == "Permission denied"
class TestPatchResult:
def test_to_dict_success(self):
r = PatchResult(success=True, diff="--- a\n+++ b", files_modified=["a.py"])
d = r.to_dict()
assert d["success"] is True
assert d["diff"] == "--- a\n+++ b"
assert d["files_modified"] == ["a.py"]
def test_to_dict_error(self):
r = PatchResult(error="File not found")
d = r.to_dict()
assert d["success"] is False
assert d["error"] == "File not found"
class TestSearchResult:
def test_to_dict_with_matches(self):
m = SearchMatch(path="a.py", line_number=10, content="hello")
r = SearchResult(matches=[m], total_count=1)
d = r.to_dict()
assert d["total_count"] == 1
assert len(d["matches"]) == 1
assert d["matches"][0]["path"] == "a.py"
def test_to_dict_empty(self):
r = SearchResult()
d = r.to_dict()
assert d["total_count"] == 0
assert "matches" not in d
def test_to_dict_files_mode(self):
r = SearchResult(files=["a.py", "b.py"], total_count=2)
d = r.to_dict()
assert d["files"] == ["a.py", "b.py"]
def test_to_dict_count_mode(self):
r = SearchResult(counts={"a.py": 3, "b.py": 1}, total_count=4)
d = r.to_dict()
assert d["counts"]["a.py"] == 3
def test_truncated_flag(self):
r = SearchResult(total_count=100, truncated=True)
d = r.to_dict()
assert d["truncated"] is True
class TestLintResult:
def test_skipped(self):
r = LintResult(skipped=True, message="No linter for .md files")
d = r.to_dict()
assert d["status"] == "skipped"
assert d["message"] == "No linter for .md files"
def test_success(self):
r = LintResult(success=True, output="")
d = r.to_dict()
assert d["status"] == "ok"
def test_error(self):
r = LintResult(success=False, output="SyntaxError line 5")
d = r.to_dict()
assert d["status"] == "error"
assert "SyntaxError" in d["output"]
# =========================================================================
# ShellFileOperations helpers
# =========================================================================
@pytest.fixture()
def mock_env():
"""Create a mock terminal environment."""
env = MagicMock()
env.cwd = "/tmp/test"
env.execute.return_value = {"output": "", "returncode": 0}
return env
@pytest.fixture()
def file_ops(mock_env):
return ShellFileOperations(mock_env)
class TestShellFileOpsHelpers:
def test_escape_shell_arg_simple(self, file_ops):
assert file_ops._escape_shell_arg("hello") == "'hello'"
def test_escape_shell_arg_with_quotes(self, file_ops):
result = file_ops._escape_shell_arg("it's")
assert "'" in result
# Should be safely escaped
assert result.count("'") >= 4 # wrapping + escaping
def test_is_likely_binary_by_extension(self, file_ops):
assert file_ops._is_likely_binary("photo.png") is True
assert file_ops._is_likely_binary("data.db") is True
assert file_ops._is_likely_binary("code.py") is False
assert file_ops._is_likely_binary("readme.md") is False
def test_is_likely_binary_by_content(self, file_ops):
# High ratio of non-printable chars -> binary
binary_content = "\x00\x01\x02\x03" * 250
assert file_ops._is_likely_binary("unknown", binary_content) is True
# Normal text -> not binary
assert file_ops._is_likely_binary("unknown", "Hello world\nLine 2\n") is False
def test_is_image(self, file_ops):
assert file_ops._is_image("photo.png") is True
assert file_ops._is_image("pic.jpg") is True
assert file_ops._is_image("icon.ico") is True
assert file_ops._is_image("data.pdf") is False
assert file_ops._is_image("code.py") is False
def test_add_line_numbers(self, file_ops):
content = "line one\nline two\nline three"
result = file_ops._add_line_numbers(content)
assert " 1|line one" in result
assert " 2|line two" in result
assert " 3|line three" in result
def test_add_line_numbers_with_offset(self, file_ops):
content = "continued\nmore"
result = file_ops._add_line_numbers(content, start_line=50)
assert " 50|continued" in result
assert " 51|more" in result
def test_add_line_numbers_truncates_long_lines(self, file_ops):
long_line = "x" * (MAX_LINE_LENGTH + 100)
result = file_ops._add_line_numbers(long_line)
assert "[truncated]" in result
def test_unified_diff(self, file_ops):
old = "line1\nline2\nline3\n"
new = "line1\nchanged\nline3\n"
diff = file_ops._unified_diff(old, new, "test.py")
assert "-line2" in diff
assert "+changed" in diff
assert "test.py" in diff
def test_cwd_from_env(self, mock_env):
mock_env.cwd = "/custom/path"
ops = ShellFileOperations(mock_env)
assert ops.cwd == "/custom/path"
def test_cwd_fallback_to_slash(self):
env = MagicMock(spec=[]) # no cwd attribute
ops = ShellFileOperations(env)
assert ops.cwd == "/"
class TestSearchPathValidation:
"""Test that search() returns an error for non-existent paths."""
def test_search_nonexistent_path_returns_error(self, mock_env):
"""search() should return an error when the path doesn't exist."""
def side_effect(command, **kwargs):
if "test -e" in command:
return {"output": "not_found", "returncode": 1}
if "command -v" in command:
return {"output": "yes", "returncode": 0}
return {"output": "", "returncode": 0}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.search("pattern", path="/nonexistent/path")
assert result.error is not None
assert "not found" in result.error.lower() or "Path not found" in result.error
def test_search_nonexistent_path_files_mode(self, mock_env):
"""search(target='files') should also return error for bad paths."""
def side_effect(command, **kwargs):
if "test -e" in command:
return {"output": "not_found", "returncode": 1}
if "command -v" in command:
return {"output": "yes", "returncode": 0}
return {"output": "", "returncode": 0}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.search("*.py", path="/nonexistent/path", target="files")
assert result.error is not None
assert "not found" in result.error.lower() or "Path not found" in result.error
def test_search_existing_path_proceeds(self, mock_env):
"""search() should proceed normally when the path exists."""
def side_effect(command, **kwargs):
if "test -e" in command:
return {"output": "exists", "returncode": 0}
if "command -v" in command:
return {"output": "yes", "returncode": 0}
# rg returns exit 1 (no matches) with empty output
return {"output": "", "returncode": 1}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.search("pattern", path="/existing/path")
assert result.error is None
assert result.total_count == 0 # No matches but no error
def test_search_rg_error_exit_code(self, mock_env):
"""search() should report error when rg returns exit code 2."""
call_count = {"n": 0}
def side_effect(command, **kwargs):
call_count["n"] += 1
if "test -e" in command:
return {"output": "exists", "returncode": 0}
if "command -v" in command:
return {"output": "yes", "returncode": 0}
# rg returns exit 2 (error) with empty output
return {"output": "", "returncode": 2}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.search("pattern", path="/some/path")
assert result.error is not None
assert "search failed" in result.error.lower() or "Search error" in result.error
class TestShellFileOpsWriteDenied:
def test_write_file_denied_path(self, file_ops):
result = file_ops.write_file("~/.ssh/authorized_keys", "evil key")
assert result.error is not None
assert "denied" in result.error.lower()
def test_patch_replace_denied_path(self, file_ops):
result = file_ops.patch_replace("~/.ssh/authorized_keys", "old", "new")
assert result.error is not None
assert "denied" in result.error.lower()