Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/longpath/cwdguard.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/longpath/rm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
59 changes: 59 additions & 0 deletions tests/test_rm_cwd.py
Original file line number Diff line number Diff line change
@@ -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()
Loading