From 3d78eaec670ae482f5904ac25faa40e906a4cbf7 Mon Sep 17 00:00:00 2001 From: rangoDJ <18576423+rangoDJ@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:40:07 -0400 Subject: [PATCH] Delete a symlink itself, not what it points to delete_path() resolved the path before deleting, so deleting a symlink in Shared Files deleted its target, and rmtree emptied a linked folder. A symlink is now unlinked on its own; everything else is unchanged. Fixes #41 Co-Authored-By: Claude Opus 5.5 --- root/app/backend/file_manager.py | 9 ++++++ tests/test_file_manager.py | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/root/app/backend/file_manager.py b/root/app/backend/file_manager.py index f31e56a..28bd15b 100644 --- a/root/app/backend/file_manager.py +++ b/root/app/backend/file_manager.py @@ -123,6 +123,15 @@ def get_file_for_download(relative_path: str) -> FileResponse: ) def delete_path(relative_path: str) -> Dict[str, Any]: + # A symlink is deleted itself, never what it points to: safe_path() resolves links, + # which would make deleting a link to a folder empty the real folder + clean_rel = Path((relative_path or "").lstrip("/\\")) + if clean_rel.name not in ("", ".", ".."): + entry = safe_path(clean_rel.parent.as_posix()) / clean_rel.name + if entry.is_symlink(): + entry.unlink() + return {"success": True, "message": f"Deleted {entry.name}"} + target = safe_path(relative_path) if not target.exists(): raise HTTPException(status_code=404, detail="File or folder not found") diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index e82c92d..97c17e1 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -145,3 +145,58 @@ def test_create_folder_rejects_a_duplicate(shared): with pytest.raises(HTTPException) as exc: fm.create_folder("", "docs") assert exc.value.status_code == 409 + + +# ----------------- Deleting a symlink ----------------- + +def make_link(link, target, is_dir): + try: + link.symlink_to(target, target_is_directory=is_dir) + except OSError: + pytest.skip("this system can't create symlinks") + + +def test_deleting_a_link_to_a_folder_keeps_the_folder(shared): + real = shared / "projects" + real.mkdir() + (real / "keep.txt").write_text("x") + make_link(shared / "shortcut", real, True) + fm.delete_path("shortcut") + assert not (shared / "shortcut").exists() and not (shared / "shortcut").is_symlink() + assert (real / "keep.txt").read_text() == "x" + + +def test_deleting_a_link_to_a_file_keeps_the_file(shared): + real = shared / "report.txt" + real.write_text("x") + make_link(shared / "docs-link.txt", real, False) + fm.delete_path("docs-link.txt") + assert real.read_text() == "x" + assert not (shared / "docs-link.txt").is_symlink() + + +def test_a_link_pointing_outside_can_still_be_deleted(shared, tmp_path): + """Removing the link touches nothing outside the shared folder.""" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret").write_text("x") + make_link(shared / "escape", outside, True) + fm.delete_path("escape") + assert (outside / "secret").read_text() == "x" + assert not (shared / "escape").is_symlink() + + +def test_a_link_inside_a_subfolder_is_deleted_itself(shared): + sub = shared / "sub" + sub.mkdir() + real = shared / "data" + real.mkdir() + make_link(sub / "data-link", real, True) + fm.delete_path("sub/data-link") + assert real.is_dir() and not (sub / "data-link").is_symlink() + + +def test_parent_traversal_is_still_rejected_on_delete(): + with pytest.raises(HTTPException) as exc: + fm.delete_path("../etc") + assert exc.value.status_code == 403