[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:
parent
8d5d2be6a7
commit
a5ae69550e
9 changed files with 175 additions and 17 deletions
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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]):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
38
tests/unit/plugins/test_ytdl_options.py
Normal file
38
tests/unit/plugins/test_ytdl_options.py
Normal 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)
|
||||||
20
tests/unit/utils/test_file_handler.py
Normal file
20
tests/unit/utils/test_file_handler.py
Normal 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("/")
|
||||||
Loading…
Reference in a new issue