Adds Overlay.type='files' whose source-of-truth IS the overlay directory
itself. Users can:
* upload arbitrary files / whole folders by dragging from the OS onto a
folder row in the file tree (one POST per file, queue with
concurrency 3, per-file progress in a floating Uploads panel)
* move via drag-and-drop inside the tree (same gesture, source
distinguishes; refuses cycles)
* create / edit / rename / replace through a single editor modal
(text flavor for editable files, binary flavor with replace-upload
for everything else; filename input is the rename surface)
* mkdir empty folders (slashes allowed for nested intermediates)
* stream a folder as a zip download
* delete files and empty folders
Backend is type-agnostic past the new files_routes endpoints, so the
existing mount / spec / overlayfs / expose_server_cfg pipeline is reused
unchanged. is_editable gates the row's edit affordance and the /save
content rules. Three new safe-resolve helpers (write/delete/move) cover
the new operations with the same anchor-and-resolve pattern as listing
and download. FilesBuilder is a no-op so the build subsystem can
dispatch uniformly.
Spec: docs/superpowers/specs/2026-05-09-files-overlay-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
507 lines
16 KiB
Python
507 lines
16 KiB
Python
"""Pure-helper tests for l4d2web.services.overlay_files.
|
|
|
|
Covers path safety (listing vs download), directory listing semantics,
|
|
symlink handling, and the children cap. Flask-free; only exercises the
|
|
filesystem.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def overlay_root(monkeypatch, tmp_path: Path) -> Path:
|
|
"""LEFT4ME_ROOT/overlays/7/ ready for tests to populate. Returns the overlay root."""
|
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
|
root = tmp_path / "overlays" / "7"
|
|
root.mkdir(parents=True)
|
|
return root
|
|
|
|
|
|
def test_safe_resolve_for_listing_returns_overlay_root_when_sub_path_empty(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
resolved = safe_resolve_for_listing("7", "")
|
|
|
|
assert resolved == overlay_root.resolve()
|
|
|
|
|
|
def test_safe_resolve_for_listing_joins_sub_path_under_overlay_root(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
(overlay_root / "left4dead2" / "addons").mkdir(parents=True)
|
|
|
|
resolved = safe_resolve_for_listing("7", "left4dead2/addons")
|
|
|
|
assert resolved == (overlay_root / "left4dead2" / "addons").resolve()
|
|
|
|
|
|
def test_safe_resolve_for_listing_rejects_dotdot(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_listing("7", "../../etc")
|
|
|
|
|
|
def test_safe_resolve_for_listing_rejects_absolute_path(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_listing("7", "/etc/passwd")
|
|
|
|
|
|
def test_safe_resolve_for_listing_rejects_empty_component(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_listing("7", "foo//bar")
|
|
|
|
|
|
def test_safe_resolve_for_listing_rejects_symlink_escaping_overlay_root(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
"""A directory inside the overlay that is itself a symlink to somewhere
|
|
outside the overlay must be refused — even if the target sits within
|
|
LEFT4ME_ROOT/workshop_cache."""
|
|
from l4d2web.services.overlay_files import safe_resolve_for_listing
|
|
|
|
cache = tmp_path / "workshop_cache" / "shared"
|
|
cache.mkdir(parents=True)
|
|
(overlay_root / "shared").symlink_to(cache)
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_listing("7", "shared")
|
|
|
|
|
|
def test_safe_resolve_for_download_rejects_empty_path(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_download("7", "")
|
|
|
|
|
|
def test_safe_resolve_for_download_returns_real_path_for_regular_file(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
target = overlay_root / "cfg" / "server.cfg"
|
|
target.parent.mkdir()
|
|
target.write_text("hostname test\n")
|
|
|
|
resolved = safe_resolve_for_download("7", "cfg/server.cfg")
|
|
|
|
assert resolved == target.resolve()
|
|
|
|
|
|
def test_safe_resolve_for_download_follows_symlink_into_workshop_cache(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
"""Workshop overlays populate addons/*.vpk as symlinks into
|
|
LEFT4ME_ROOT/workshop_cache/. Download must follow the symlink and
|
|
return the cache target."""
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
cache_file = tmp_path / "workshop_cache" / "12345.vpk"
|
|
cache_file.parent.mkdir(parents=True)
|
|
cache_file.write_bytes(b"vpk-bytes")
|
|
|
|
addons = overlay_root / "left4dead2" / "addons"
|
|
addons.mkdir(parents=True)
|
|
(addons / "deathcraft.vpk").symlink_to(cache_file)
|
|
|
|
resolved = safe_resolve_for_download("7", "left4dead2/addons/deathcraft.vpk")
|
|
|
|
assert resolved == cache_file.resolve()
|
|
|
|
|
|
def test_safe_resolve_for_download_rejects_symlink_outside_left4me_root(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
"""A malicious script overlay that plants a symlink at evil → /etc/passwd
|
|
must be refused."""
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
outside = tmp_path.parent / "outside-left4me-root.txt"
|
|
outside.write_text("nope")
|
|
(overlay_root / "evil").symlink_to(outside)
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_download("7", "evil")
|
|
|
|
|
|
def test_safe_resolve_for_download_rejects_dotdot(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_download("7", "../../etc/passwd")
|
|
|
|
|
|
def test_safe_resolve_for_download_rejects_absolute_path(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_download
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_download("7", "/etc/passwd")
|
|
|
|
|
|
def test_list_directory_returns_empty_for_empty_dir(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
entries, truncated = list_directory(overlay_root, overlay_root)
|
|
|
|
assert entries == []
|
|
assert truncated == 0
|
|
|
|
|
|
def test_list_directory_returns_dirs_before_files_alphabetically(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "Zeta").mkdir()
|
|
(overlay_root / "alpha").mkdir()
|
|
(overlay_root / "Beta-file.txt").write_text("b")
|
|
(overlay_root / "alpha-file.txt").write_text("a")
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
names = [e["name"] for e in entries]
|
|
|
|
# Dirs first (case-insensitive alpha), then files (case-insensitive alpha).
|
|
assert names == ["alpha", "Zeta", "alpha-file.txt", "Beta-file.txt"]
|
|
|
|
|
|
def test_list_directory_marks_kind_dir_or_file(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "subdir").mkdir()
|
|
(overlay_root / "leaf.txt").write_text("hi")
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
by_name = {e["name"]: e for e in entries}
|
|
|
|
assert by_name["subdir"]["kind"] == "dir"
|
|
assert by_name["leaf.txt"]["kind"] == "file"
|
|
|
|
|
|
def test_list_directory_includes_relative_path_under_overlay_root(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
addons = overlay_root / "left4dead2" / "addons"
|
|
addons.mkdir(parents=True)
|
|
(addons / "foo.vpk").write_text("v")
|
|
|
|
entries, _ = list_directory(addons, overlay_root)
|
|
|
|
assert entries[0]["rel"] == "left4dead2/addons/foo.vpk"
|
|
|
|
|
|
def test_list_directory_marks_symlinks_with_resolved_size(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
cache_file = tmp_path / "workshop_cache" / "12345.vpk"
|
|
cache_file.parent.mkdir(parents=True)
|
|
cache_file.write_bytes(b"x" * 4096)
|
|
|
|
addons = overlay_root / "left4dead2" / "addons"
|
|
addons.mkdir(parents=True)
|
|
(addons / "deathcraft.vpk").symlink_to(cache_file)
|
|
|
|
entries, _ = list_directory(addons, overlay_root)
|
|
entry = entries[0]
|
|
|
|
assert entry["is_symlink"] is True
|
|
assert entry["broken"] is False
|
|
assert entry["size"] == 4096
|
|
assert entry["kind"] == "file"
|
|
|
|
|
|
def test_list_directory_marks_broken_symlinks(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "missing.vpk").symlink_to(overlay_root / "does-not-exist.vpk")
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
entry = entries[0]
|
|
|
|
assert entry["is_symlink"] is True
|
|
assert entry["broken"] is True
|
|
assert entry["size"] is None
|
|
assert entry["kind"] == "file"
|
|
|
|
|
|
def test_list_directory_truncates_at_cap(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
for i in range(7):
|
|
(overlay_root / f"f{i:02d}.txt").write_text("x")
|
|
|
|
entries, truncated = list_directory(overlay_root, overlay_root, max_entries=5)
|
|
|
|
assert len(entries) == 5
|
|
assert truncated == 2
|
|
assert [e["name"] for e in entries] == [f"f{i:02d}.txt" for i in range(5)]
|
|
|
|
|
|
def test_list_directory_includes_size_human_for_files(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "tiny.txt").write_text("hello")
|
|
(overlay_root / "big.bin").write_bytes(b"x" * (3 * 1024 * 1024))
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
by_name = {e["name"]: e for e in entries}
|
|
|
|
# Files only — directories don't have size_human.
|
|
assert by_name["tiny.txt"]["size_human"] == "5 B"
|
|
assert by_name["big.bin"]["size_human"] == "3.0 MB"
|
|
|
|
|
|
# ---- safe_resolve_for_write -------------------------------------------------
|
|
|
|
|
|
def test_safe_resolve_for_write_returns_path_under_overlay_root(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_write
|
|
|
|
resolved = safe_resolve_for_write("7", "left4dead2/cfg/server.cfg")
|
|
|
|
assert resolved == overlay_root / "left4dead2" / "cfg" / "server.cfg"
|
|
|
|
|
|
def test_safe_resolve_for_write_rejects_empty_path(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_write
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_write("7", "")
|
|
|
|
|
|
def test_safe_resolve_for_write_rejects_dotdot(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_write
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_write("7", "../escape.txt")
|
|
|
|
|
|
def test_safe_resolve_for_write_rejects_overwriting_symlink(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_write
|
|
|
|
target = tmp_path / "outside.txt"
|
|
target.write_text("nope")
|
|
(overlay_root / "evil").symlink_to(target)
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_write("7", "evil")
|
|
|
|
|
|
def test_safe_resolve_for_write_rejects_path_with_non_dir_parent(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_write
|
|
|
|
(overlay_root / "blocker").write_text("file, not dir")
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_write("7", "blocker/child.txt")
|
|
|
|
|
|
# ---- safe_resolve_for_delete -----------------------------------------------
|
|
|
|
|
|
def test_safe_resolve_for_delete_returns_resolvable_path(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
|
|
|
target = overlay_root / "cfg" / "server.cfg"
|
|
target.parent.mkdir()
|
|
target.write_text("x")
|
|
|
|
resolved = safe_resolve_for_delete("7", "cfg/server.cfg")
|
|
|
|
assert resolved == target
|
|
|
|
|
|
def test_safe_resolve_for_delete_rejects_dotdot(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_delete("7", "../neighbour")
|
|
|
|
|
|
def test_safe_resolve_for_delete_rejects_empty(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_delete("7", "")
|
|
|
|
|
|
def test_safe_resolve_for_delete_rejects_symlink_escaping_root(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_delete
|
|
|
|
outside = tmp_path / "outside-link.txt"
|
|
outside.write_text("nope")
|
|
(overlay_root / "evil").symlink_to(outside)
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_delete("7", "evil")
|
|
|
|
|
|
# ---- safe_resolve_for_move -------------------------------------------------
|
|
|
|
|
|
def test_safe_resolve_for_move_returns_paths_when_valid(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
src = overlay_root / "motd.txt"
|
|
src.write_text("welcome")
|
|
(overlay_root / "addons").mkdir()
|
|
|
|
src_path, dst_path = safe_resolve_for_move("7", "motd.txt", "addons/motd.txt")
|
|
|
|
assert src_path == src
|
|
assert dst_path == overlay_root / "addons" / "motd.txt"
|
|
|
|
|
|
def test_safe_resolve_for_move_rejects_missing_src(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_move("7", "nope.txt", "addons/nope.txt")
|
|
|
|
|
|
def test_safe_resolve_for_move_rejects_dst_parent_not_directory(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
(overlay_root / "src.txt").write_text("x")
|
|
(overlay_root / "blocker").write_text("file, not dir")
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_move("7", "src.txt", "blocker/dst.txt")
|
|
|
|
|
|
def test_safe_resolve_for_move_rejects_dst_inside_src(overlay_root: Path) -> None:
|
|
"""Moving a directory into itself or a descendant must fail before any
|
|
rename happens."""
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
src = overlay_root / "addons"
|
|
src.mkdir()
|
|
(src / "child").mkdir()
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_move("7", "addons", "addons/child/addons")
|
|
|
|
|
|
def test_safe_resolve_for_move_rejects_overwrite_of_symlink(
|
|
overlay_root: Path, tmp_path: Path
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
(overlay_root / "src.txt").write_text("x")
|
|
(overlay_root / "dst").symlink_to(tmp_path / "outside.txt")
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_move("7", "src.txt", "dst")
|
|
|
|
|
|
def test_safe_resolve_for_move_rejects_dotdot(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import safe_resolve_for_move
|
|
|
|
(overlay_root / "src.txt").write_text("x")
|
|
|
|
with pytest.raises(ValueError):
|
|
safe_resolve_for_move("7", "src.txt", "../escape.txt")
|
|
|
|
|
|
# ---- is_editable -----------------------------------------------------------
|
|
|
|
|
|
def test_is_editable_true_for_small_utf8_file(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import is_editable
|
|
|
|
target = overlay_root / "motd.txt"
|
|
target.write_text("Welcome\nHave fun.\n")
|
|
|
|
assert is_editable(target) is True
|
|
|
|
|
|
def test_is_editable_false_for_oversized_file(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import is_editable
|
|
|
|
target = overlay_root / "huge.bin"
|
|
# 1 MiB + 1 byte
|
|
target.write_bytes(b"a" * (1024 * 1024 + 1))
|
|
|
|
assert is_editable(target) is False
|
|
|
|
|
|
def test_is_editable_false_for_binary_content_in_first_8kib(
|
|
overlay_root: Path,
|
|
) -> None:
|
|
from l4d2web.services.overlay_files import is_editable
|
|
|
|
target = overlay_root / "fake.vpk"
|
|
# Random binary bytes in the sniff window — should fail strict UTF-8.
|
|
target.write_bytes(bytes(range(256)) * 40)
|
|
|
|
assert is_editable(target) is False
|
|
|
|
|
|
def test_is_editable_false_for_symlink(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import is_editable
|
|
|
|
real = overlay_root / "real.txt"
|
|
real.write_text("hi")
|
|
link = overlay_root / "link.txt"
|
|
link.symlink_to(real)
|
|
|
|
assert is_editable(link) is False
|
|
|
|
|
|
def test_is_editable_false_for_directory(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import is_editable
|
|
|
|
sub = overlay_root / "subdir"
|
|
sub.mkdir()
|
|
|
|
assert is_editable(sub) is False
|
|
|
|
|
|
def test_entry_dict_marks_editable_for_small_utf8_file(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "small.txt").write_text("hello")
|
|
(overlay_root / "big.bin").write_bytes(b"\x00" * 200)
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
by_name = {e["name"]: e for e in entries}
|
|
|
|
assert by_name["small.txt"]["editable"] is True
|
|
assert by_name["big.bin"]["editable"] is False
|
|
|
|
|
|
def test_entry_dict_marks_directories_not_editable(overlay_root: Path) -> None:
|
|
from l4d2web.services.overlay_files import list_directory
|
|
|
|
(overlay_root / "subdir").mkdir()
|
|
|
|
entries, _ = list_directory(overlay_root, overlay_root)
|
|
by_name = {e["name"]: e for e in entries}
|
|
|
|
assert by_name["subdir"]["editable"] is False
|