diff --git a/src/longpath/cwdguard.py b/src/longpath/cwdguard.py new file mode 100644 index 0000000..706be04 --- /dev/null +++ b/src/longpath/cwdguard.py @@ -0,0 +1,31 @@ +"""Refuse deletes that would wipe the working directory or its parent.""" + +from __future__ import annotations + +import os +from typing import Optional + + +def cwd_delete_reason(path: str) -> Optional[str]: + """Why *path* must not be deleted, or ``None`` if the cwd guard is silent. + + Root-device refusal stays in :func:`longpath.rm.rm_path` so this only + covers the current working directory and its immediate parent. + """ + try: + target = os.path.realpath(os.path.abspath(path)) + cwd = os.path.realpath(os.getcwd()) + except OSError: + return None + if os.path.normcase(target) == os.path.normcase(cwd): + return "refusing to delete the current working directory" + parent = os.path.dirname(cwd) + if not parent or os.path.dirname(parent) == parent: + return None + try: + parent_real = os.path.realpath(parent) + except OSError: + return None + if os.path.normcase(target) == os.path.normcase(parent_real): + return "refusing to delete the parent of the current working directory" + return None diff --git a/src/longpath/rm.py b/src/longpath/rm.py index 741753b..7159279 100644 --- a/src/longpath/rm.py +++ b/src/longpath/rm.py @@ -19,6 +19,7 @@ from typing import List, Tuple from .core import WINDOWS, displayable, ext_path, safe_walk, unext +from .cwdguard import cwd_delete_reason @dataclass @@ -97,6 +98,10 @@ def rm_path(path: str, *, dry_run: bool = False) -> RmResult: if os.path.dirname(plain) == plain: result.errors.append((plain, "refusing to delete a filesystem root")) return result + cwd_reason = cwd_delete_reason(plain) + if cwd_reason: + result.errors.append((plain, cwd_reason)) + return result real = ext_path(path) diff --git a/tests/test_rm_cwd.py b/tests/test_rm_cwd.py new file mode 100644 index 0000000..cd66537 --- /dev/null +++ b/tests/test_rm_cwd.py @@ -0,0 +1,59 @@ +"""CWD / parent delete guards — separate from --top / py.typed themes.""" + +from __future__ import annotations + +import os + +from longpath.cwdguard import cwd_delete_reason +from longpath.rm import rm_path + +from conftest import mkfile, run_cli + + +def test_cwd_delete_reason_for_dot_and_cwd(): + assert "current working directory" in (cwd_delete_reason(".") or "") + assert "current working directory" in (cwd_delete_reason(os.getcwd()) or "") + + +def test_cwd_delete_reason_for_parent(): + reason = cwd_delete_reason("..") + assert reason is not None + assert "parent" in reason + + +def test_rm_path_refuses_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mkfile(str(tmp_path / "keep.txt")) + result = rm_path(".") + assert not result.ok + assert "current working directory" in result.errors[0][1] + assert (tmp_path / "keep.txt").exists() + + +def test_rm_path_refuses_parent_of_cwd(tmp_path, monkeypatch): + child = tmp_path / "child" + child.mkdir() + mkfile(str(tmp_path / "keep.txt")) + monkeypatch.chdir(child) + result = rm_path("..") + assert not result.ok + assert "parent" in result.errors[0][1] + assert (tmp_path / "keep.txt").exists() + + +def test_rm_path_still_deletes_sibling(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + doomed = tmp_path / "doomed" + mkfile(str(doomed / "x.txt")) + result = rm_path(str(doomed)) + assert result.ok, result.errors + assert not doomed.exists() + + +def test_cli_rm_dot_exits_2(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mkfile(str(tmp_path / "keep.txt")) + p = run_cli("rm", ".", "-y", cwd=str(tmp_path)) + assert p.returncode == 2 + assert "current working directory" in p.stdout + p.stderr + assert (tmp_path / "keep.txt").exists()