[BACKEND] Validate write permissions on working directory and output directory (#1395)

Closes https://github.com/jmbannon/ytdl-sub/issues/1394
Should help debug user issues like https://github.com/jmbannon/ytdl-sub/issues/1148

Checks permissions on the following:
- working directory
- output directory
- cookiefile (if specified)

And will fail immediately with a verbose message.
This commit is contained in:
Jesse Bannon 2025-12-06 15:54:41 -08:00 committed by GitHub
parent 8d5d2be6a7
commit a5ae69550e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 175 additions and 17 deletions

View file

@ -12,6 +12,8 @@ from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH
from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
@ -147,6 +149,12 @@ class ConfigOptions(StrictDictValidator):
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
) )
if not FileHandler.is_path_writable(self.working_directory):
raise SubscriptionPermissionError(
"ytdl-sub does not have permissions to the working directory: "
f"{self.working_directory}"
)
@property @property
def working_directory(self) -> str: def working_directory(self) -> str:
""" """

View file

@ -8,6 +8,9 @@ from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_operation import PluginOperation from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsDictValidator from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.entries.script.variable_definitions import VARIABLES as v from ytdl_sub.entries.script.variable_definitions import VARIABLES as v
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
@ -57,12 +60,24 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator):
def to_native_dict(self, overrides: Overrides) -> Dict: def to_native_dict(self, overrides: Overrides) -> Dict:
""" """
Materializes the entire ytdl-options dict from OverrideStringFormatters into Materializes the entire ytdl-options dict from OverrideStringFormatters into
native python native python.
""" """
return { out = {
key: overrides.apply_overrides_formatter_to_native(val) key: overrides.apply_overrides_formatter_to_native(val)
for key, val in self.dict.items() for key, val in self.dict.items()
} }
if "cookiefile" in out:
if not FileHandler.is_file_existent(out["cookiefile"]):
raise ValidationException(
f"Specified cookiefile {out['cookiefile']} but it does not exist as a file."
)
if not FileHandler.is_file_readable(out["cookiefile"]):
raise SubscriptionPermissionError(
f"Cannot read cookiefile {out['cookiefile']} due to permissions issue."
)
return out
# Disable for proper docstring formatting # Disable for proper docstring formatting

View file

@ -10,6 +10,8 @@ from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.entries.variables.override_variables import SubscriptionVariables from ytdl_sub.entries.variables.override_variables import SubscriptionVariables
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -94,6 +96,12 @@ class BaseSubscription(ABC):
self._exception: Optional[Exception] = None self._exception: Optional[Exception] = None
if not FileHandler.is_path_writable(self.output_directory):
raise SubscriptionPermissionError(
"ytdl-sub does not have write permissions to the output directory: "
f"{self.output_directory}"
)
@property @property
def download_archive(self) -> EnhancedDownloadArchive: def download_archive(self) -> EnhancedDownloadArchive:
""" """

View file

@ -40,3 +40,7 @@ class FileNotDownloadedException(ValueError):
class ExperimentalFeatureNotEnabled(ValidationException): class ExperimentalFeatureNotEnabled(ValidationException):
"""Feature is not enabled for usage""" """Feature is not enabled for usage"""
class SubscriptionPermissionError(ValidationException):
"""Early-caught permission error"""

View file

@ -379,6 +379,36 @@ class FileHandler:
""" """
return self._file_handler_transaction_log return self._file_handler_transaction_log
@classmethod
def is_path_writable(cls, src_file_path: Union[str, Path]) -> bool:
"""
Check whether a path is writable. If it does not exist, try to find the base directory
and check permissions on that.
"""
path = os.path.abspath(src_file_path)
while not os.path.exists(path):
new_path = os.path.dirname(path)
if new_path == path: # reached root
break
path = new_path
return os.access(path, os.W_OK)
@classmethod
def is_file_existent(cls, file_path: Union[str, Path]) -> bool:
"""
Check whether a file exists.
"""
return os.path.isfile(file_path)
@classmethod
def is_file_readable(cls, file_path: Union[str, Path]) -> bool:
"""
Check whether a file exists and is readable.
"""
return cls.is_file_existent(file_path) and os.access(file_path, os.R_OK)
@classmethod @classmethod
def copy(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]): def copy(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]):
""" """

View file

@ -13,6 +13,7 @@ from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Union
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -165,19 +166,18 @@ def preset_dict_to_dl_args(preset_dict: Dict) -> str:
@pytest.fixture @pytest.fixture
def preset_dict_to_subscription_yaml_generator() -> Callable: def subscription_yaml_file_generator() -> Callable:
@contextlib.contextmanager @contextlib.contextmanager
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict): def _subscription_yaml_file_generator(yaml_dict: Dict):
subscription_dict = {subscription_name: preset_dict}
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file: with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(subscription_dict).encode("utf-8")) tmp_file.write(json.dumps(yaml_dict).encode("utf-8"))
try: try:
yield tmp_file.name yield tmp_file.name
finally: finally:
FileHandler.delete(tmp_file.name) FileHandler.delete(tmp_file.name)
return _preset_dict_to_subscription_yaml_generator return _subscription_yaml_file_generator
################################################################################################### ###################################################################################################
@ -224,8 +224,14 @@ def _load_config(config_path: Path, working_directory: str) -> ConfigFile:
@pytest.fixture() @pytest.fixture()
def music_video_subscription_path() -> Path: def music_video_subscription_path(
return Path("examples/music_video_subscriptions.yaml") output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/music_video_subscriptions.yaml")
yaml["__preset__"]["overrides"]["music_video_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture() @pytest.fixture()
@ -239,13 +245,25 @@ def tv_show_config(working_directory, tv_show_config_path) -> ConfigFile:
@pytest.fixture() @pytest.fixture()
def tv_show_subscriptions_path() -> Path: def tv_show_subscriptions_path(
return Path("examples/tv_show_subscriptions.yaml") output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/tv_show_subscriptions.yaml")
yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture() @pytest.fixture()
def advanced_tv_show_subscriptions_path() -> Path: def advanced_tv_show_subscriptions_path(
return Path("examples/advanced/tv_show_subscriptions.yaml") output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/advanced/tv_show_subscriptions.yaml")
yaml["__preset__"] = {"overrides": {"tv_show_directory": output_directory}}
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture() @pytest.fixture()
@ -265,8 +283,25 @@ def default_config_path(default_config) -> str:
@pytest.fixture() @pytest.fixture()
def music_subscriptions_path() -> Path: def music_subscriptions_path(
return Path("examples/music_subscriptions.yaml") output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/music_subscriptions.yaml")
yaml["__preset__"]["overrides"]["music_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture()
def docker_default_subscription_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("docker/root/defaults/subscriptions.yaml")
yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
def mock_run_from_cli(args: str) -> List[Subscription]: def mock_run_from_cli(args: str) -> List[Subscription]:

View file

@ -540,9 +540,9 @@ def test_music_video_subscriptions(default_config: ConfigFile, music_video_subsc
assert gnr.get("url2").native == "https://www.youtube.com/watch?v=OldpIhHPsbs" assert gnr.get("url2").native == "https://www.youtube.com/watch?v=OldpIhHPsbs"
def test_default_docker_config_and_subscriptions(): def test_default_docker_config_and_subscriptions(docker_default_subscription_path: Path):
default_config = ConfigFile.from_file_path("docker/root/defaults/config.yaml") default_config = ConfigFile.from_file_path("docker/root/defaults/config.yaml")
default_subs = Subscription.from_file_path( default_subs = Subscription.from_file_path(
config=default_config, subscription_path=Path("docker/root/defaults/subscriptions.yaml") config=default_config, subscription_path=docker_default_subscription_path
) )
assert len(default_subs) == 1 assert len(default_subs) == 1

View file

@ -0,0 +1,38 @@
import re
from typing import Any
from typing import Dict
import pytest
from conftest import get_match_filters
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
def preset_dict(output_directory) -> Dict[str, Any]:
return {
"download": "https://your.name.here",
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
}
class TestYtdlOptions:
def test_cookiefile_does_not_exist(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
):
preset_dict["ytdl_options"] = {
"cookiefile": "/path/to/nowhere",
}
error_msg = "Specified cookiefile /path/to/nowhere but it does not exist as a file."
with pytest.raises(ValidationException, match=re.escape(error_msg)):
Subscription.from_dict(
config=default_config,
preset_name="test_ytdl_options",
preset_dict=preset_dict,
).download(dry_run=False)

View file

@ -0,0 +1,20 @@
import pytest
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.system import IS_WINDOWS
class TestFileHandler:
def test_directory_exists(self):
if IS_WINDOWS:
return
assert FileHandler.is_path_writable("/tmp")
assert FileHandler.is_path_writable("/tmp/")
assert FileHandler.is_path_writable("/tmp/non-existent")
assert FileHandler.is_path_writable("/tmp/non-existent/")
assert FileHandler.is_path_writable("/tmp/non-existent/nested")
assert not FileHandler.is_path_writable("/lol-in-root")
assert not FileHandler.is_path_writable("/")