## Release v0.51.243 — Release HK (stage-q15) UX-approved direction via Telegram (workspace feature you named). Backend security hardened through 3 Codex rounds. ### Added | PR | Author | Feature | |----|--------|---------| | #3402 / #3422 | @pamnard | **Drag a file or folder in the workspace tree onto another folder row (or breadcrumb segment) to move it** within the workspace. New `POST /api/file/move`. Drop handlers use `stopPropagation` so the composer `@path` drag (#1097) and OS-file upload-drop (#3411) are unchanged. | ### Verified - Live end-to-end: `notes.txt` → `docs/` confirmed on disk; legit moves return the correct `new_path`. - 13 tests (incl. folder-into-self/descendant guard, existing-target collision, and 3 security regressions). ### Security hardening absorbed (3 Codex rounds — all fixed + regression-tested) 1. **TOCTOU symlink race on destination**: a path-based `source.rename(dest)` could be raced by swapping `dest_dir` to an external symlink between validation and rename. Now opens both parent dirs via the workspace-anchored `open_anchored_fd` (openat + `O_NOFOLLOW`, same helper as the upload hardening) and uses `os.rename(leaf, leaf, src_dir_fd=…, dst_dir_fd=…)` with an fd-based collision check; path-based fallback only where `dir_fd` is unsupported. 2. **Symlinked workspace root** returned a confusing 400 after a successful move — returned `new_path` now computed against `ws_root.resolve()`. 3. **Symlinked source entry**: `safe_resolve` follows the final symlink, so moving `link.txt` would move its *target* and dangle the link — now rejected via no-follow `lstat` on the lexical path. ### Gate - Full pytest suite: **7530 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES ×3 → all applied + tested → **SAFE TO SHIP** Co-authored-by: pamnard <pamnard@users.noreply.github.com>
250 lines
9.8 KiB
Python
250 lines
9.8 KiB
Python
"""Tests for #3402 part A — move files/folders within the workspace tree."""
|
|
import re
|
|
|
|
|
|
def _src(name: str) -> str:
|
|
with open(f"static/{name}") as f:
|
|
return f.read()
|
|
|
|
|
|
ROUTES = open("api/routes.py", encoding="utf-8").read()
|
|
|
|
|
|
class TestIssue3402WorkspaceTreeMoveApi:
|
|
def test_file_move_route_registered(self):
|
|
assert 'parsed.path == "/api/file/move"' in ROUTES
|
|
assert "return _handle_file_move(handler, body)" in ROUTES
|
|
|
|
def test_file_move_handler_requires_dest_dir(self):
|
|
block = ROUTES[ROUTES.index("def _handle_file_move"):ROUTES.index("def _handle_file_move") + 4000]
|
|
assert 'require(body, "session_id", "path", "dest_dir")' in block
|
|
# The move performs a rename. As of the #3422 security hardening the
|
|
# primary path uses a workspace-anchored os.rename(..., src_dir_fd=...,
|
|
# dst_dir_fd=...) (TOCTOU-safe) with a path-based source.rename(dest)
|
|
# fallback on platforms without dir_fd support — accept either.
|
|
assert ("os.rename(" in block and "dst_dir_fd=" in block) or "source.rename(dest)" in block
|
|
assert "Cannot move a folder into itself or its subfolder" in block
|
|
|
|
|
|
def test_file_move():
|
|
"""Moving a file into another folder changes its path on disk."""
|
|
from tests.test_sprint14 import make_session, post
|
|
|
|
created = []
|
|
try:
|
|
sid, _sess = make_session(created)
|
|
post("/api/file/create-dir", {"session_id": sid, "path": "subdir"})
|
|
post("/api/file/create", {"session_id": sid, "path": "note.txt", "content": "hello"})
|
|
d, status = post("/api/file/move", {
|
|
"session_id": sid,
|
|
"path": "note.txt",
|
|
"dest_dir": "subdir",
|
|
})
|
|
assert status == 200
|
|
assert d["ok"] is True
|
|
assert d["new_path"] == "subdir/note.txt"
|
|
finally:
|
|
for s in created:
|
|
post("/api/session/delete", {"session_id": s})
|
|
|
|
|
|
def test_file_move_rejects_folder_into_itself():
|
|
from tests.test_sprint14 import make_session, post
|
|
|
|
created = []
|
|
try:
|
|
sid, _sess = make_session(created)
|
|
post("/api/file/create-dir", {"session_id": sid, "path": "parent"})
|
|
d, status = post("/api/file/move", {
|
|
"session_id": sid,
|
|
"path": "parent",
|
|
"dest_dir": "parent",
|
|
})
|
|
assert status == 400
|
|
assert "subfolder" in d.get("error", "").lower() or "itself" in d.get("error", "").lower()
|
|
finally:
|
|
for s in created:
|
|
post("/api/session/delete", {"session_id": s})
|
|
|
|
|
|
def test_file_move_rejects_existing_target():
|
|
from tests.test_sprint14 import make_session, post
|
|
|
|
created = []
|
|
try:
|
|
sid, _sess = make_session(created)
|
|
post("/api/file/create-dir", {"session_id": sid, "path": "dest"})
|
|
post("/api/file/create", {"session_id": sid, "path": "a.txt", "content": "a"})
|
|
post("/api/file/create", {"session_id": sid, "path": "dest/a.txt", "content": "b"})
|
|
d, status = post("/api/file/move", {
|
|
"session_id": sid,
|
|
"path": "a.txt",
|
|
"dest_dir": "dest",
|
|
})
|
|
assert status == 400
|
|
finally:
|
|
for s in created:
|
|
post("/api/session/delete", {"session_id": s})
|
|
|
|
|
|
def test_file_move_rejects_symlinked_dest_dir_escape():
|
|
"""Security (#3422 hardening): a dest_dir that is a symlink pointing OUTSIDE
|
|
the workspace must not let the move escape — the workspace-anchored
|
|
open_anchored_fd (openat + O_NOFOLLOW) rejects any symlinked path component,
|
|
and the file must remain inside the workspace."""
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from tests.test_sprint14 import make_session, post
|
|
|
|
created = []
|
|
outside = tempfile.mkdtemp()
|
|
try:
|
|
sid, sess = make_session(created)
|
|
ws = Path(sess["workspace"])
|
|
post("/api/file/create", {"session_id": sid, "path": "a.txt", "content": "secret"})
|
|
# Plant a symlink inside the workspace pointing outside it.
|
|
link = ws / "evil_link"
|
|
try:
|
|
os.symlink(outside, link)
|
|
except (OSError, NotImplementedError):
|
|
return # platform without symlink support — nothing to test
|
|
d, status = post("/api/file/move", {
|
|
"session_id": sid,
|
|
"path": "a.txt",
|
|
"dest_dir": "evil_link",
|
|
})
|
|
assert status == 400, f"symlinked dest_dir escape must be rejected, got {status}"
|
|
# The file must NOT have escaped into the outside dir.
|
|
assert not os.path.exists(os.path.join(outside, "a.txt")), (
|
|
"file escaped the workspace via a symlinked dest_dir"
|
|
)
|
|
assert (ws / "a.txt").exists(), "source file should be left in place on rejection"
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(outside, ignore_errors=True)
|
|
for s in created:
|
|
post("/api/session/delete", {"session_id": s})
|
|
|
|
|
|
def test_file_move_succeeds_under_symlinked_workspace_root():
|
|
"""Regression (#3422 hardening follow-up): a move inside a workspace whose
|
|
configured root is itself a symlink (e.g. macOS /tmp -> /private/tmp) must
|
|
return ok with the correct relative new_path — the returned path is computed
|
|
against ws_root.resolve(), so the post-rename relative_to() can't raise after
|
|
a successful on-disk move. Driven at the handler level (the workspace-trust
|
|
layer rejects an arbitrary symlinked path via the public /api/session/new)."""
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
import api.routes as routes
|
|
|
|
real = tempfile.mkdtemp()
|
|
link = real + "_link"
|
|
try:
|
|
try:
|
|
os.symlink(real, link)
|
|
except (OSError, NotImplementedError):
|
|
return
|
|
os.mkdir(os.path.join(real, "dest"))
|
|
Path(real, "a.txt").write_text("x")
|
|
|
|
class _S:
|
|
workspace = link # configured root is the SYMLINK, not the real dir
|
|
|
|
cap = {}
|
|
orig_j, orig_bad, orig_get = routes.j, routes.bad, routes.get_session_for_file_ops
|
|
routes.j = lambda h, o: (cap.__setitem__("ok", o), True)[1]
|
|
routes.bad = lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1]
|
|
routes.get_session_for_file_ops = lambda sid: _S()
|
|
try:
|
|
routes._handle_file_move(
|
|
type("H", (), {})(),
|
|
{"session_id": "x", "path": "a.txt", "dest_dir": "dest"},
|
|
)
|
|
finally:
|
|
routes.j, routes.bad, routes.get_session_for_file_ops = orig_j, orig_bad, orig_get
|
|
assert "ok" in cap, f"move under symlinked workspace root must succeed, got {cap}"
|
|
assert cap["ok"]["new_path"] == "dest/a.txt", cap["ok"]
|
|
assert os.path.exists(os.path.join(real, "dest", "a.txt"))
|
|
finally:
|
|
import shutil
|
|
try:
|
|
os.unlink(link)
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(real, ignore_errors=True)
|
|
|
|
|
|
def test_file_move_rejects_symlinked_source_entry():
|
|
"""Security (#3422 hardening, round 3): dragging a SYMLINK entry must not
|
|
move the link's resolved target. safe_resolve() follows the final symlink,
|
|
so without a guard moving link.txt (-> dir/real.txt) would move
|
|
dir/real.txt and leave link.txt dangling. The handler rejects a symlinked
|
|
source (lstat on the lexically-requested path) with 400, leaving both the
|
|
link and its target untouched."""
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
import api.routes as routes
|
|
|
|
real_ws = tempfile.mkdtemp()
|
|
try:
|
|
os.mkdir(os.path.join(real_ws, "dir"))
|
|
os.mkdir(os.path.join(real_ws, "dest"))
|
|
Path(real_ws, "dir", "real.txt").write_text("important")
|
|
try:
|
|
os.symlink(os.path.join(real_ws, "dir", "real.txt"), os.path.join(real_ws, "link.txt"))
|
|
except (OSError, NotImplementedError):
|
|
return
|
|
|
|
class _S:
|
|
workspace = real_ws
|
|
|
|
cap = {}
|
|
orig_j, orig_bad, orig_get = routes.j, routes.bad, routes.get_session_for_file_ops
|
|
routes.j = lambda h, o: (cap.__setitem__("ok", o), True)[1]
|
|
routes.bad = lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1]
|
|
routes.get_session_for_file_ops = lambda sid: _S()
|
|
try:
|
|
routes._handle_file_move(
|
|
type("H", (), {})(),
|
|
{"session_id": "x", "path": "link.txt", "dest_dir": "dest"},
|
|
)
|
|
finally:
|
|
routes.j, routes.bad, routes.get_session_for_file_ops = orig_j, orig_bad, orig_get
|
|
assert "bad" in cap, f"symlinked source must be rejected, got {cap}"
|
|
# The real target must NOT have moved, and the link must remain.
|
|
assert os.path.exists(os.path.join(real_ws, "dir", "real.txt")), "symlink target was moved!"
|
|
assert os.path.islink(os.path.join(real_ws, "link.txt")), "symlink source was removed!"
|
|
assert not os.path.exists(os.path.join(real_ws, "dest", "real.txt"))
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(real_ws, ignore_errors=True)
|
|
|
|
|
|
class TestIssue3402WorkspaceTreeMoveUi:
|
|
def test_render_tree_items_bind_move_drop_on_dirs(self):
|
|
src = _src("ui.js")
|
|
assert "_bindWorkspaceMoveDropTarget(el,item.path)" in src
|
|
|
|
def test_move_drop_stops_propagation(self):
|
|
src = _src("ui.js")
|
|
block = src[src.index("function _bindWorkspaceMoveDropTarget"):src.index("function _renderTreeItems")]
|
|
assert block.count("e.stopPropagation()") >= 3
|
|
|
|
def test_move_calls_file_move_api(self):
|
|
src = _src("ui.js")
|
|
assert "await api('/api/file/move'" in src
|
|
|
|
def test_composer_ws_path_drag_still_copy(self):
|
|
src = _src("ui.js")
|
|
m = re.search(r"el\.ondragstart=\(e\)=>\{[^}]+\}", src)
|
|
assert m
|
|
assert "effectAllowed='copy'" in m.group(0)
|
|
|
|
def test_move_drop_css_classes_exist(self):
|
|
css = open("static/style.css", encoding="utf-8").read()
|
|
assert ".file-item.dragging" in css
|
|
assert ".file-item.drag-over" in css
|