fix: resolve relative folder paths against anime dir in delete_series
The folder column in anime_series stores the relative folder name
(e.g. 'Beyblade Burst (2016)'), not an absolute path. The old
delete_series code called os.path.abspath(folder_path) directly,
which joins a relative path against the process current working
directory. Inside the container the FastAPI app runs with CWD=/app
while the anime directory is /data, so 'Beyblade Burst (2016)'
resolved to '/app/Beyblade Burst (2016)' and the is_safe_path check
correctly (but unhelpfully) flagged it as outside the /data base,
skipping the folder delete while still removing the row.
Fix: resolve relative folder paths against the configured anime
directory before validating against the base. Absolute paths still
work unchanged.
Also harden is_safe_path() the same way so a relative target is
treated as relative to base_path, not to the process CWD. Path
traversal ('../etc/passwd') is still rejected.
Adds two regression tests:
- is_safe_path with chdir to '/' and relative target resolves
against the base
- delete_series with chdir to '/', relative folder in DB,
succeeds and removes the folder
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for AnimeService.delete_series()."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -328,6 +329,69 @@ class TestDeleteSeriesService:
|
||||
assert result.folder_error is not None
|
||||
assert "outside" in result.folder_error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_relative_folder_with_different_cwd(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""Regression: delete_series must work when the stored folder is relative
|
||||
and the process CWD differs from directory_to_search.
|
||||
|
||||
In the container the FastAPI app runs with CWD=/app while the anime
|
||||
directory is /data. The DB stores the relative folder name (e.g.
|
||||
"Beyblade Burst (2016)"). The old code called
|
||||
``os.path.abspath(folder)`` which joined against CWD=/app and
|
||||
produced "/app/Beyblade Burst (2016)", which was then rejected as
|
||||
outside the /data base. The fix resolves relative paths against
|
||||
the configured anime directory instead.
|
||||
"""
|
||||
safe_base = tmp_path / "data"
|
||||
safe_base.mkdir()
|
||||
series_folder = safe_base / "Beyblade Burst (2016)"
|
||||
series_folder.mkdir()
|
||||
|
||||
anime_service._directory = str(safe_base)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
# Stored folder is RELATIVE (matches what's actually in the DB)
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "beyblade-burst"
|
||||
mock_series.name = "Beyblade Burst"
|
||||
mock_series.folder = "Beyblade Burst (2016)"
|
||||
mock_series.id = 336
|
||||
|
||||
# Simulate process CWD differing from anime dir (container case:
|
||||
# CWD=/app while anime dir is /data). Use "/" as a stable, always-
|
||||
# existing CWD distinct from tmp_path.
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
# Folder MUST be deleted successfully
|
||||
assert result.deleted_folder is True, (
|
||||
f"folder delete failed: success={result.success} "
|
||||
f"folder_error={result.folder_error!r}"
|
||||
)
|
||||
assert result.folder_error is None
|
||||
assert result.success is True
|
||||
assert not series_folder.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,34 @@ class TestIsSafePath:
|
||||
"/anime/Attack on Titan/Season 1/Episode 1"
|
||||
)
|
||||
|
||||
def test_relative_target_resolved_against_base(self):
|
||||
"""Relative targets resolve against the base, not the process CWD.
|
||||
|
||||
Regression test: previously `os.path.abspath(target_path)` would
|
||||
join a relative target against the process's current working
|
||||
directory. When the CWD differed from `base_path` (e.g. the
|
||||
FastAPI app running with CWD=/app while the anime directory is
|
||||
/data), a relative folder name like "Beyblade Burst (2016)"
|
||||
would be resolved to "/app/Beyblade Burst (2016)" and
|
||||
incorrectly rejected as outside the base. The helper now
|
||||
treats a relative target as relative to `base_path`.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = os.path.abspath(tmpdir)
|
||||
# Simulate a process CWD different from base
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
# Relative target inside base should be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)")
|
||||
# Nested relative target should also be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)/Season 1")
|
||||
# Relative traversal (../) must still be rejected even
|
||||
# when resolved against the base
|
||||
assert not is_safe_path(base, "../etc/passwd")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
class TestCreateSafeFolder:
|
||||
"""Test create_safe_folder function."""
|
||||
|
||||
Reference in New Issue
Block a user