"""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