diff --git a/files_mcp/core/fs_ops.py b/files_mcp/core/fs_ops.py new file mode 100644 index 0000000..1d3b968 --- /dev/null +++ b/files_mcp/core/fs_ops.py @@ -0,0 +1,177 @@ +# coding=utf-8 +""" +@Time :2026/6/30 +@Author :tao.chen + +Pure file-system operations on already-resolved Path objects. Callers +are responsible for sandbox resolution (see path_guard.resolve_and_check). +No global state, no logging — kept trivially testable. +""" +import os +import shutil +import tempfile +from pathlib import Path + +from files_mcp.models import FileEntry, OperationResult + +MAX_BYTES = 1_048_576 # 1 MB cap, mirrors read_job_file / update_job_file + + +def _to_entry(p: Path) -> FileEntry: + st = p.lstat() # lstat: do not follow symlink for type detection + if p.is_symlink(): + kind = "symlink" + elif p.is_dir(): + kind = "directory" + elif p.is_file(): + kind = "file" + else: + kind = "other" + return FileEntry( + path=str(p), + name=p.name, + type=kind, + size=st.st_size if kind == "file" else 0, + mtime=st.st_mtime, + mode=st.st_mode, + ) + + +def _check_size(content: str) -> None: + if len(content.encode("utf-8")) > MAX_BYTES: + raise ValueError( + f"content too large: {len(content)} bytes > {MAX_BYTES}" + ) + + +def _atomic_write(path: Path, content: str) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp_", suffix=path.suffix) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def create(path: Path, content: str, overwrite: bool) -> OperationResult: + if path.exists() and not overwrite: + raise FileExistsError(f"file already exists: {path}") + _check_size(content) + _atomic_write(path, content) + return OperationResult(path=str(path), status="CREATED", size=path.stat().st_size) + + +def read(path: Path) -> dict: + if not path.exists(): + raise KeyError(f"path not found: {path}") + if not path.is_file(): + raise ValueError(f"path {path!r} is not a regular file") + raw = path.read_bytes() + if len(raw) > MAX_BYTES: + raise ValueError(f"file too large: {len(raw)} bytes > {MAX_BYTES}") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError(f"not valid utf-8: {path}") from e + return {"path": str(path), "content": text, "size": len(raw), "encoding": "utf-8"} + + +def update(path: Path, content: str) -> OperationResult: + if not path.exists(): + raise KeyError(f"path not found: {path}") + _check_size(content) + _atomic_write(path, content) + return OperationResult(path=str(path), status="UPDATED", size=path.stat().st_size) + + +def delete(path: Path, recursive: bool) -> OperationResult: + if not path.exists(): + raise KeyError(f"path not found: {path}") + if path.is_dir() and not recursive: + # refuse to delete non-empty dirs unless recursive + if any(path.iterdir()): + raise ValueError( + f"directory {path!r} is not empty (use recursive=True)" + ) + if path.is_dir(): + if recursive: + shutil.rmtree(path) + else: + # empty dir (non-empty case raised above) + path.rmdir() + else: + path.unlink() + return OperationResult(path=str(path), status="DELETED", size=None) + + +def list_dir(path: Path, recursive: bool, max_depth: int) -> list[FileEntry]: + if not path.exists(): + raise KeyError(f"path not found: {path}") + if not path.is_dir(): + raise ValueError(f"path {path!r} is not a directory") + if max_depth < 0: + raise ValueError("max_depth must be >= 0") + out: list[FileEntry] = [] + if not recursive: + max_depth = 0 + for cur, dirs, files in os.walk(path): + rel = Path(cur).relative_to(path) + depth = 0 if str(rel) == "." else len(rel.parts) + if depth > max_depth: + dirs[:] = [] # don't descend further + continue + for d in dirs: + out.append(_to_entry(Path(cur) / d)) + for f in files: + out.append(_to_entry(Path(cur) / f)) + return out + + +def stat(path: Path) -> FileEntry: + if not path.exists() and not path.is_symlink(): + raise KeyError(f"path not found: {path}") + return _to_entry(path) + + +def search(path: Path, pattern: str, recursive: bool) -> list[str]: + if not path.exists(): + raise KeyError(f"path not found: {path}") + if not path.is_dir(): + raise ValueError(f"path {path!r} is not a directory") + glob_fn = path.rglob if recursive else path.glob + return [str(p) for p in glob_fn(pattern)] + + +def move(src: Path, dst: Path, overwrite: bool) -> OperationResult: + if not src.exists(): + raise KeyError(f"src not found: {src}") + if dst.exists() and not overwrite: + raise FileExistsError(f"dst already exists: {dst}") + if dst.exists() and overwrite: + if dst.is_dir() and not dst.is_symlink(): + shutil.rmtree(dst) + else: + dst.unlink() + os.replace(src, dst) + size = dst.stat().st_size if dst.is_file() else None + return OperationResult(path=str(dst), status="MOVED", size=size) + + +def copy(src: Path, dst: Path, overwrite: bool) -> OperationResult: + if not src.exists(): + raise KeyError(f"src not found: {src}") + if dst.exists() and not overwrite: + raise FileExistsError(f"dst already exists: {dst}") + if src.is_dir(): + if dst.exists() and overwrite: + shutil.rmtree(dst) + shutil.copytree(src, dst) + size = None + else: + if dst.exists() and overwrite: + dst.unlink() + shutil.copy2(src, dst) + size = dst.stat().st_size + return OperationResult(path=str(dst), status="COPIED", size=size) diff --git a/files_mcp/models.py b/files_mcp/models.py new file mode 100644 index 0000000..eff8560 --- /dev/null +++ b/files_mcp/models.py @@ -0,0 +1,23 @@ +# coding=utf-8 +""" +@Time :2026/6/30 +@Author :tao.chen +""" +from typing import Literal + +from pydantic import BaseModel + + +class FileEntry(BaseModel): + path: str # absolute, resolved, inside sandbox + name: str + type: Literal["file", "directory", "symlink", "other"] + size: int # bytes; 0 for directories + mtime: float # unix seconds + mode: int # st_mode (octal, e.g. 0o644) + + +class OperationResult(BaseModel): + path: str + status: str # "CREATED" | "UPDATED" | "DELETED" | "MOVED" | "COPIED" + size: int | None # None for delete/move diff --git a/tests/unit/test_fs_ops.py b/tests/unit/test_fs_ops.py new file mode 100644 index 0000000..d2ddcdd --- /dev/null +++ b/tests/unit/test_fs_ops.py @@ -0,0 +1,298 @@ +# coding=utf-8 +""" +@Time :2026/6/30 +@Author :tao.chen +""" +from pathlib import Path + +import pytest + +from files_mcp.core import fs_ops +from files_mcp.models import FileEntry, OperationResult + + +# --- create ------------------------------------------------------------ + +class TestCreate: + def test_creates_new_file(self, tmp_path: Path): + target = tmp_path / "new.txt" + result = fs_ops.create(target, "hello", overwrite=False) + assert isinstance(result, OperationResult) + assert result.status == "CREATED" + assert result.size == 5 + assert target.read_text(encoding="utf-8") == "hello" + + def test_overwrite_true_replaces(self, tmp_path: Path): + target = tmp_path / "f.txt" + target.write_text("old", encoding="utf-8") + result = fs_ops.create(target, "new", overwrite=True) + assert result.status == "CREATED" + assert target.read_text(encoding="utf-8") == "new" + + def test_overwrite_false_raises(self, tmp_path: Path): + target = tmp_path / "f.txt" + target.write_text("old", encoding="utf-8") + with pytest.raises(FileExistsError, match="already exists"): + fs_ops.create(target, "new", overwrite=False) + + def test_too_large_raises(self, tmp_path: Path): + target = tmp_path / "big.txt" + with pytest.raises(ValueError, match="content too large"): + fs_ops.create(target, "x" * (fs_ops.MAX_BYTES + 1), overwrite=False) + + def test_atomic_write_leaves_no_tmp_on_success(self, tmp_path: Path): + target = tmp_path / "f.txt" + fs_ops.create(target, "hi", overwrite=False) + # No leftover .tmp_* in the directory + leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".tmp_")] + assert leftovers == [] + + +# --- read -------------------------------------------------------------- + +class TestRead: + def test_reads_existing(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("hello", encoding="utf-8") + out = fs_ops.read(f) + assert out == { + "path": str(f), + "content": "hello", + "size": 5, + "encoding": "utf-8", + } + + def test_missing_raises_keyerror(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.read(tmp_path / "nope.txt") + + def test_directory_raises(self, tmp_path: Path): + with pytest.raises(ValueError, match="not a regular file"): + fs_ops.read(tmp_path) + + def test_too_large_raises(self, tmp_path: Path): + f = tmp_path / "big.bin" + f.write_bytes(b"x" * (fs_ops.MAX_BYTES + 1)) + with pytest.raises(ValueError, match="file too large"): + fs_ops.read(f) + + def test_non_utf8_raises(self, tmp_path: Path): + f = tmp_path / "binary.bin" + f.write_bytes(b"\xff\xfe\x00\x01") + with pytest.raises(ValueError, match="not valid utf-8"): + fs_ops.read(f) + + +# --- update ------------------------------------------------------------ + +class TestUpdate: + def test_updates_existing(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("old", encoding="utf-8") + result = fs_ops.update(f, "new") + assert result.status == "UPDATED" + assert result.size == 3 + assert f.read_text(encoding="utf-8") == "new" + + def test_missing_raises_keyerror(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.update(tmp_path / "nope.txt", "x") + + def test_too_large_raises(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("old", encoding="utf-8") + with pytest.raises(ValueError, match="content too large"): + fs_ops.update(f, "x" * (fs_ops.MAX_BYTES + 1)) + + +# --- delete ------------------------------------------------------------ + +class TestDelete: + def test_deletes_file(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("x", encoding="utf-8") + result = fs_ops.delete(f, recursive=False) + assert result.status == "DELETED" + assert result.size is None + assert not f.exists() + + def test_missing_raises_keyerror(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.delete(tmp_path / "nope.txt", recursive=False) + + def test_non_empty_dir_raises_without_recursive(self, tmp_path: Path): + d = tmp_path / "d" + d.mkdir() + (d / "child").write_text("x", encoding="utf-8") + with pytest.raises(ValueError, match="not empty"): + fs_ops.delete(d, recursive=False) + + def test_recursive_deletes_tree(self, tmp_path: Path): + d = tmp_path / "d" + d.mkdir() + (d / "child").write_text("x", encoding="utf-8") + (d / "sub").mkdir() + (d / "sub" / "leaf").write_text("y", encoding="utf-8") + fs_ops.delete(d, recursive=True) + assert not d.exists() + + def test_empty_dir_ok_without_recursive(self, tmp_path: Path): + d = tmp_path / "empty" + d.mkdir() + fs_ops.delete(d, recursive=False) + assert not d.exists() + + +# --- list_dir ---------------------------------------------------------- + +class TestListDir: + def test_single_level(self, tmp_path: Path): + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + (tmp_path / "b").mkdir() + entries = fs_ops.list_dir(tmp_path, recursive=False, max_depth=1) + names = sorted(e.name for e in entries) + assert names == ["a.txt", "b"] + assert all(isinstance(e, FileEntry) for e in entries) + + def test_recursive_respects_max_depth(self, tmp_path: Path): + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.txt").write_text("b", encoding="utf-8") + (tmp_path / "sub" / "deep").mkdir() + (tmp_path / "sub" / "deep" / "c.txt").write_text("c", encoding="utf-8") + depth0 = fs_ops.list_dir(tmp_path, recursive=True, max_depth=0) + assert sorted(e.name for e in depth0) == ["a.txt", "sub"] + depth1 = fs_ops.list_dir(tmp_path, recursive=True, max_depth=1) + assert sorted(e.name for e in depth1) == ["a.txt", "b.txt", "deep", "sub"] + + def test_missing_raises(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.list_dir(tmp_path / "nope", recursive=False, max_depth=1) + + def test_file_raises(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("x", encoding="utf-8") + with pytest.raises(ValueError, match="not a directory"): + fs_ops.list_dir(f, recursive=False, max_depth=1) + + def test_negative_depth_raises(self, tmp_path: Path): + with pytest.raises(ValueError, match="max_depth"): + fs_ops.list_dir(tmp_path, recursive=True, max_depth=-1) + + +# --- stat -------------------------------------------------------------- + +class TestStat: + def test_file(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("hello", encoding="utf-8") + e = fs_ops.stat(f) + assert e.name == "f.txt" + assert e.type == "file" + assert e.size == 5 + + def test_directory(self, tmp_path: Path): + e = fs_ops.stat(tmp_path) + assert e.type == "directory" + assert e.size == 0 + + def test_missing_raises(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.stat(tmp_path / "nope") + + +# --- search ------------------------------------------------------------ + +class TestSearch: + def test_simple_glob(self, tmp_path: Path): + (tmp_path / "a.py").write_text("a", encoding="utf-8") + (tmp_path / "b.txt").write_text("b", encoding="utf-8") + (tmp_path / "c.py").write_text("c", encoding="utf-8") + matches = fs_ops.search(tmp_path, "*.py", recursive=False) + assert sorted(Path(m).name for m in matches) == ["a.py", "c.py"] + + def test_recursive_glob(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "x.py").write_text("x", encoding="utf-8") + (tmp_path / "a.py").write_text("a", encoding="utf-8") + matches = fs_ops.search(tmp_path, "*.py", recursive=True) + assert sorted(Path(m).name for m in matches) == ["a.py", "x.py"] + + def test_no_match_returns_empty(self, tmp_path: Path): + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + assert fs_ops.search(tmp_path, "*.py", recursive=False) == [] + + def test_missing_raises(self, tmp_path: Path): + with pytest.raises(KeyError, match="not found"): + fs_ops.search(tmp_path / "nope", "*", recursive=False) + + def test_non_directory_raises(self, tmp_path: Path): + f = tmp_path / "f.txt" + f.write_text("x", encoding="utf-8") + with pytest.raises(ValueError, match="not a directory"): + fs_ops.search(f, "*", recursive=False) + + +# --- move / copy ------------------------------------------------------- + +class TestMove: + def test_rename(self, tmp_path: Path): + src = tmp_path / "a.txt" + src.write_text("hi", encoding="utf-8") + result = fs_ops.move(src, tmp_path / "b.txt", overwrite=False) + assert result.status == "MOVED" + assert result.path == str(tmp_path / "b.txt") + assert not src.exists() + assert (tmp_path / "b.txt").read_text(encoding="utf-8") == "hi" + + def test_missing_src_raises(self, tmp_path: Path): + with pytest.raises(KeyError, match="src not found"): + fs_ops.move(tmp_path / "nope", tmp_path / "dst", overwrite=False) + + def test_existing_dst_raises_without_overwrite(self, tmp_path: Path): + src = tmp_path / "a.txt" + src.write_text("a", encoding="utf-8") + dst = tmp_path / "b.txt" + dst.write_text("b", encoding="utf-8") + with pytest.raises(FileExistsError, match="dst already exists"): + fs_ops.move(src, dst, overwrite=False) + + def test_overwrite_replaces(self, tmp_path: Path): + src = tmp_path / "a.txt" + src.write_text("new", encoding="utf-8") + dst = tmp_path / "b.txt" + dst.write_text("old", encoding="utf-8") + fs_ops.move(src, dst, overwrite=True) + assert dst.read_text(encoding="utf-8") == "new" + assert not src.exists() + + +class TestCopy: + def test_copies_file(self, tmp_path: Path): + src = tmp_path / "a.txt" + src.write_text("hi", encoding="utf-8") + result = fs_ops.copy(src, tmp_path / "b.txt", overwrite=False) + assert result.status == "COPIED" + assert result.size == 2 + assert src.read_text(encoding="utf-8") == "hi" # source unchanged + assert (tmp_path / "b.txt").read_text(encoding="utf-8") == "hi" + + def test_missing_src_raises(self, tmp_path: Path): + with pytest.raises(KeyError, match="src not found"): + fs_ops.copy(tmp_path / "nope", tmp_path / "dst", overwrite=False) + + def test_existing_dst_raises_without_overwrite(self, tmp_path: Path): + src = tmp_path / "a.txt" + src.write_text("a", encoding="utf-8") + dst = tmp_path / "b.txt" + dst.write_text("b", encoding="utf-8") + with pytest.raises(FileExistsError, match="dst already exists"): + fs_ops.copy(src, dst, overwrite=False) + + def test_copies_directory(self, tmp_path: Path): + src = tmp_path / "d" + src.mkdir() + (src / "child").write_text("x", encoding="utf-8") + result = fs_ops.copy(src, tmp_path / "d2", overwrite=False) + assert result.status == "COPIED" + assert (tmp_path / "d2" / "child").read_text(encoding="utf-8") == "x"