File size: 13,330 Bytes
b5b9c2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | """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()
def test_delete_file_denied_path(self, file_ops):
result = file_ops.delete_file("~/.ssh/authorized_keys")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_src_denied(self, file_ops):
result = file_ops.move_file("~/.ssh/id_rsa", "/tmp/dest.txt")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_dst_denied(self, file_ops):
result = file_ops.move_file("/tmp/src.txt", "~/.aws/credentials")
assert result.error is not None
assert "denied" in result.error.lower()
def test_move_file_failure_path(self, mock_env):
mock_env.execute.return_value = {"output": "No such file or directory", "returncode": 1}
ops = ShellFileOperations(mock_env)
result = ops.move_file("/tmp/nonexistent.txt", "/tmp/dest.txt")
assert result.error is not None
assert "Failed to move" in result.error
|