from __future__ import annotations import os import shutil from pathlib import Path import pytest from fastapi.testclient import TestClient from app.errors import InvalidPath from app.paths import input_file, output_file, resolve_inside from .conftest import AUTH @pytest.fixture def root(tmp_path: Path) -> Path: path = tmp_path / "root" path.mkdir() (path / "a.txt").write_text("a") return path @pytest.mark.parametrize( "requested", [ "relative.txt", "", "/etc/passwd", "{root}/../outside.txt", "{root}/sub/../../outside.txt", "{root}/a\0.txt", ], ) def test_rejects_paths_outside_root(root: Path, requested: str) -> None: with pytest.raises(InvalidPath): resolve_inside(str(root), requested.format(root=root)) def test_accepts_paths_inside_root(root: Path) -> None: assert resolve_inside(str(root), f"{root}/a.txt") == os.path.realpath(root / "a.txt") assert resolve_inside(str(root), f"{root}/new/dir/file.mkv").endswith("/new/dir/file.mkv") def test_rejects_symlink_escape(root: Path, tmp_path: Path) -> None: outside = tmp_path / "outside" outside.mkdir() (outside / "secret.mp4").write_text("x") os.symlink(outside, root / "link") os.symlink(outside / "secret.mp4", root / "file-link.mp4") with pytest.raises(InvalidPath): input_file(str(root), f"{root}/link/secret.mp4") with pytest.raises(InvalidPath): input_file(str(root), f"{root}/file-link.mp4") with pytest.raises(InvalidPath): output_file(str(root), f"{root}/link/new/out.mkv") assert not (outside / "new").exists() def test_symlink_inside_root_is_allowed(root: Path) -> None: os.symlink(root / "a.txt", root / "alias.txt") assert input_file(str(root), f"{root}/alias.txt") == os.path.realpath(root / "a.txt") def test_missing_input_and_directory_input(root: Path) -> None: with pytest.raises(InvalidPath): input_file(str(root), f"{root}/missing.mp4") (root / "dir").mkdir() with pytest.raises(InvalidPath): input_file(str(root), f"{root}/dir") def test_output_parent_is_created(root: Path) -> None: real = output_file(str(root), f"{root}/renders/r1/out.mkv") assert os.path.isdir(os.path.dirname(real)) def test_http_rejects_traversal_and_symlink_escape( client: TestClient, media_root: Path, tmp_path: Path ) -> None: outside = tmp_path / "elsewhere" outside.mkdir() shutil.copy(media_root / "testsrc.mp4", outside / "clip.mp4") link = media_root / "escape-link" if not link.exists(): os.symlink(outside, link) for path in ["/etc/hostname", f"{media_root}/../x.mp4", f"{link}/clip.mp4", "testsrc.mp4"]: response = client.post("/v1/detectScenes", headers=AUTH, json={"path": path}) assert response.status_code == 400, path assert response.json()["error"] == { "code": "invalid_path", "message": response.json()["error"]["message"], "operation": "detectScenes", } response = client.post( "/v1/redact", headers=AUTH, json={ "path": f"{media_root}/testsrc.mp4", "outPath": f"{link}/out.mkv", "redactions": [ { "keyframes": [{"tMs": 0, "box": {"x": 0, "y": 0, "w": 0.1, "h": 0.1}}], "mode": "blur", "paddingPct": 0, "featherPct": 0, } ], }, ) assert response.status_code == 400 assert response.json()["error"]["code"] == "invalid_path" assert not (outside / "out.mkv").exists() def test_input_size_limit(make_client, media_root: Path) -> None: client = make_client(max_input_bytes=10) response = client.post("/v1/audioFeatures", headers=AUTH, json={"path": f"{media_root}/testsrc.mp4"}) assert response.status_code == 413 assert response.json()["error"]["code"] == "too_large" def test_duration_limit(make_client, media_root: Path) -> None: client = make_client(max_seconds=1) response = client.post("/v1/audioFeatures", headers=AUTH, json={"path": f"{media_root}/testsrc.mp4"}) assert response.status_code == 413