Skip to content

Commit fa3b0f5

Browse files
committed
Implements file duplication with robust path validation and enhanced frontend error handling
1 parent a2c61c6 commit fa3b0f5

4 files changed

Lines changed: 61 additions & 8 deletions

File tree

frontend/src/components/editor/file-tree/requesting-tree.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class RequestingTree {
7979
async copy(id: string, newName: string): Promise<void> {
8080
const node = this.delegate.find(id);
8181
if (!node) {
82+
toast({
83+
title: "Failed",
84+
description: `Node with id ${id} not found in the tree`,
85+
});
8286
return;
8387
}
8488
const currentPath = node.data.path as FilePath;
@@ -106,6 +110,10 @@ export class RequestingTree {
106110
async rename(id: string, name: string): Promise<void> {
107111
const node = this.delegate.find(id);
108112
if (!node) {
113+
toast({
114+
title: "Failed",
115+
description: `Node with id ${id} not found in the tree`,
116+
});
109117
return;
110118
}
111119
const currentPath = node.data.path as FilePath;
@@ -201,6 +209,10 @@ export class RequestingTree {
201209
async delete(id: string): Promise<void> {
202210
const node = this.delegate.find(id);
203211
if (!node) {
212+
toast({
213+
title: "Failed",
214+
description: `Node with id ${id} not found in the tree`,
215+
});
204216
return;
205217
}
206218

marimo/_server/api/endpoints/file_explorer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ async def delete_file_or_directory(
159159
"""
160160
body = await parse_request(request, cls=FileDeleteRequest)
161161
try:
162+
# TODO: Refactor this side-effect based validation to a dedicated validation.
162163
file_system.get_details(body.path)
163164
success = file_system.delete_file_or_directory(body.path)
164165
return FileDeleteResponse(success=success)
@@ -189,6 +190,7 @@ async def copy_file_or_directory(
189190
"""
190191
body = await parse_request(request, cls=FileCopyRequest)
191192
try:
193+
# TODO: Refactor this side-effect based validation to a dedicated validation.
192194
file_system.get_details(body.path)
193195
info = file_system.copy_file_or_directory(body.path, body.new_path)
194196
return FileCopyResponse(success=True, info=info)
@@ -219,6 +221,7 @@ async def move_file_or_directory(
219221
"""
220222
body = await parse_request(request, cls=FileMoveRequest)
221223
try:
224+
# TODO: Refactor this side-effect based validation to a dedicated validation.
222225
file_system.get_details(body.path)
223226
info = file_system.move_file_or_directory(body.path, body.new_path)
224227
return FileMoveResponse(success=True, info=info)
@@ -250,6 +253,7 @@ async def update_file(
250253
app_state = AppState(request)
251254
body = await parse_request(request, cls=FileUpdateRequest)
252255
try:
256+
# TODO: Refactor this side-effect based validation to a dedicated validation.
253257
file_system.get_details(body.path)
254258
info = file_system.update_file(body.path, body.contents)
255259

@@ -285,6 +289,7 @@ async def open_file(
285289
"""
286290
body = await parse_request(request, cls=FileOpenRequest)
287291
try:
292+
# TODO: Refactor this side-effect based validation to a dedicated validation.
288293
file_system.get_details(body.path)
289294
success = file_system.open_in_editor(body.path, body.line_number)
290295
return SuccessResponse(success=success)

marimo/_server/files/os_file_system.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,22 +188,20 @@ def delete_file_or_directory(self, path: str) -> bool:
188188

189189
def copy_file_or_directory(self, path: str, new_path: str) -> FileInfo:
190190
new_path = str(_generate_unique_path(new_path))
191+
if not _is_allowed_paths(path, new_path):
192+
raise ValueError(f"Cannot copy to {new_path}")
191193
if Path(path).is_dir():
192194
shutil.copytree(path, new_path)
193195
else:
194196
shutil.copy2(path, new_path)
195197
return self.get_details(new_path).file
196198

197199
def move_file_or_directory(self, path: str, new_path: str) -> FileInfo:
198-
file_name = os.path.basename(new_path)
199-
# Disallow renaming to . or ..
200-
if file_name in DISALLOWED_NAMES:
200+
if not _is_allowed_paths(path, new_path):
201201
raise ValueError(f"Cannot rename to {new_path}")
202-
# Disallow moving to an existing path or directory
203-
if os.path.exists(new_path) or os.path.isdir(new_path):
204-
raise ValueError(
205-
f"Destination path {new_path} already exists or is a directory"
206-
)
202+
# Disallow moving to an existing path
203+
if os.path.exists(new_path):
204+
raise ValueError(f"Destination path {new_path} already exists")
207205
safe_move(path, new_path)
208206
return self.get_details(new_path).file
209207

@@ -472,3 +470,13 @@ def _generate_unique_path(new_path: str | Path) -> Path:
472470
if not new_path.exists():
473471
return new_path
474472
i += 1
473+
474+
475+
def _is_allowed_paths(path: str | Path, new_path: str | Path) -> bool:
476+
file_name = os.path.basename(new_path)
477+
if file_name in DISALLOWED_NAMES or not file_name.strip():
478+
return False
479+
480+
src = Path(path).resolve()
481+
dst = Path(new_path).resolve()
482+
return not (src.is_dir() and dst.is_relative_to(src))

tests/_server/files/test_os_file_system.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import sys
34
from pathlib import Path
45
from typing import Literal
56

@@ -8,6 +9,7 @@
89
from marimo._server.files.os_file_system import (
910
OSFileSystem,
1011
_generate_unique_path,
12+
_is_allowed_paths,
1113
)
1214
from marimo._server.models.files import FileDetailsResponse
1315
from marimo._utils.files import natural_sort
@@ -701,3 +703,29 @@ def test_generate_unique_path_existing_path(tmp_path: Path) -> None:
701703
unique_path = _generate_unique_path(base_path)
702704
assert not unique_path.exists()
703705
assert unique_path.name == "file_2.txt"
706+
707+
708+
@pytest.mark.parametrize("new_path", [".", "..", ""])
709+
def test_is_allowed_paths_disallowed_names(new_path: str) -> None:
710+
assert not _is_allowed_paths("path/to/file.txt", f"path/to/{new_path}")
711+
712+
713+
def test_is_allowed_paths_subdirectory(tmp_path: Path) -> None:
714+
base_path = tmp_path / "subdir"
715+
base_path.mkdir()
716+
assert not _is_allowed_paths(tmp_path, base_path)
717+
assert not _is_allowed_paths(base_path, base_path)
718+
assert not _is_allowed_paths(base_path, base_path / "../subdir/subsubdir")
719+
assert _is_allowed_paths(base_path, base_path / "../subdir2")
720+
721+
722+
@pytest.mark.skipif(
723+
sys.platform == "win32",
724+
reason="Symlinks on Windows require Admin privileges",
725+
)
726+
def test_is_allowed_paths_recursive_symlink(tmp_path: Path) -> None:
727+
src = tmp_path / "src"
728+
src.mkdir()
729+
dst_link = tmp_path / "link_to_src"
730+
dst_link.symlink_to(src)
731+
assert not _is_allowed_paths(src, dst_link / "child")

0 commit comments

Comments
 (0)