diff --git a/bad_path/checker.py b/bad_path/checker.py index 74bed58..ed75d66 100644 --- a/bad_path/checker.py +++ b/bad_path/checker.py @@ -16,7 +16,6 @@ class DangerousPathError(PermissionError): """Exception raised when a dangerous path is detected.""" - # Module-level list of user-defined dangerous paths _user_defined_paths: list[str] = [] @@ -112,11 +111,17 @@ def get_dangerous_paths() -> list[str]: """ match platform.system(): case "Windows": - from .platforms.windows import system_paths + from .platforms.windows.paths import ( + system_paths, + ) # pylint: disable=import-outside-toplevel case "Darwin": - from .platforms.darwin import system_paths + from .platforms.darwin.paths import ( + system_paths, + ) # pylint: disable=import-outside-toplevel case _: # Linux and other Unix-like systems - from .platforms.posix import system_paths + from .platforms.posix.paths import ( + system_paths, + ) # pylint: disable=import-outside-toplevel # Merge system paths and user-defined paths using sets to avoid duplicates all_paths = set(system_paths) | set(_user_defined_paths) @@ -461,7 +466,9 @@ def _check_cwd_traversal(self, path_obj: Path | None = None) -> bool: # If other resolution fails, treat as dangerous return True - def _check_against_paths(self, paths: list[str], path_obj: Path | None = None) -> bool: + def _check_against_paths( + self, paths: list[str], path_obj: Path | None = None + ) -> bool: """Check if a path matches any in the given list. Args: @@ -517,7 +524,9 @@ def _check_invalid_chars(self, path_str: str | None = None) -> bool: return False - def __call__(self, path: str | Path | None = None, raise_error: bool = False) -> bool: + def __call__( + self, path: str | Path | None = None, raise_error: bool = False + ) -> bool: """Check a path for danger, with optional path reload. Note: Unlike the boolean context (which returns True for safe paths), @@ -585,7 +594,9 @@ def __call__(self, path: str | Path | None = None, raise_error: bool = False) -> is_dangerous = True if is_dangerous and raise_error: - raise DangerousPathError(f"Path '{path}' points to a dangerous location") + raise DangerousPathError( + f"Path '{path}' points to a dangerous location" + ) return is_dangerous else: @@ -594,7 +605,9 @@ def __call__(self, path: str | Path | None = None, raise_error: bool = False) -> is_dangerous = self._is_dangerous() if is_dangerous and raise_error: - raise DangerousPathError(f"Path '{self._path}' points to a dangerous location") + raise DangerousPathError( + f"Path '{self._path}' points to a dangerous location" + ) return is_dangerous @@ -739,176 +752,10 @@ def __repr__(self) -> str: # Platform Classes # ============================================================================ - -class WindowsPathChecker(BasePathChecker): - """Windows-specific PathChecker implementation. - - Handles Windows-specific path validation including drive letters, reserved names, - and Windows-specific invalid characters. - """ - - def _load_invalid_chars(self) -> None: - """Load Windows-specific invalid characters and reserved names.""" - from .platforms.windows import invalid_chars, reserved_names - self._invalid_chars = invalid_chars - self._reserved_names = reserved_names - - def _load_and_check_paths(self) -> None: - """Load system and user paths, then check the current path against them.""" - from .platforms.windows import system_paths - self._system_paths = system_paths - self._user_paths = get_user_paths() - - # Check both types - self._is_system_path = self._check_against_paths(self._system_paths) - self._is_user_path = self._check_against_paths(self._user_paths) - - def _check_cwd_traversal(self, path_obj: Path | None = None) -> bool: - """Check if a path traverses outside the current working directory. - - Windows-specific implementation with case-insensitive comparison. - - Keyword Parameters: - path_obj (Path | None): - Optional Path object to check. If not provided, uses self._path_obj. - Defaults to None. - - Returns: - (bool): - True if the path is outside CWD (dangerous), False otherwise. - """ - if path_obj is None: - path_obj = self._path_obj - try: - cwd = Path.cwd().resolve() - - # Check if path equals CWD (handles "." case) - # Use case-insensitive string comparison for Windows - if str(path_obj).lower() == str(cwd).lower(): - return False # Path is CWD itself (safe) - - # Also try samefile() if paths exist (handles symlinks, etc.) - try: - if path_obj.exists() and cwd.exists() and path_obj.samefile(cwd): - return False # Same file/directory (safe) - except (OSError, ValueError, AttributeError): - # samefile() not available or failed, continue with relative_to - pass - - # Try to express path_obj relative to cwd - # If this succeeds, the path is within CWD - path_obj.relative_to(cwd) - return False # Path is within CWD (safe) - except ValueError: - # relative_to raised ValueError, so path is outside CWD - return True # Path is outside CWD (dangerous) - except (OSError, RuntimeError): - # If other resolution fails, treat as dangerous - return True - - def _check_invalid_chars(self, path_str: str | None = None) -> bool: - """Check for Windows-specific invalid characters. - - Includes special handling for: - - Drive letter colons (C:, D:, etc.) - - Reserved names (CON, PRN, AUX, etc.) - - Paths ending with space or period - - Keyword Parameters: - path_str (str | None): - Optional path string to check. If not provided, uses self._path. - Defaults to None. - - Returns: - (bool): - True if the path contains invalid characters, False otherwise. - """ - if path_str is None: - path_str = str(self._path_obj) - - # Check for invalid characters - for char in self._invalid_chars: - if char in path_str: - # Special handling for colon on Windows (valid in drive letters like C:) - if char == ":": - # Check if colon is part of a drive letter (e.g., C:, D:) - # Valid pattern: single letter followed by colon at start of path - if len(path_str) >= 2 and path_str[1] == ":" and path_str[0].isalpha(): - # This is a valid drive letter if it's the only colon - if path_str.count(":") == 1: - continue # This is a valid drive letter colon - return True - - # Check for reserved names (case-insensitive) - # Extract the filename from the path using string operations - # to avoid Path() issues with invalid characters - # Split by both forward slash and backslash - path_parts = path_str.replace("\\", "/").split("/") - if path_parts: - filename = path_parts[-1] - - # Extract name without extension - if "." in filename: - name_without_ext = filename.rsplit(".", 1)[0].upper() - else: - name_without_ext = filename.upper() - - # Check if the name (without extension) is a reserved name - if name_without_ext in self._reserved_names: - return True - - # Check if filename ends with space or period (invalid in Windows) - if filename and (filename.endswith(" ") or filename.endswith(".")): - return True - - return False - - -class DarwinPathChecker(BasePathChecker): - """Darwin (macOS)-specific PathChecker implementation. - - Handles macOS-specific path validation including restrictions on colons - in file names and macOS system directories. - """ - - def _load_invalid_chars(self) -> None: - """Load Darwin-specific invalid characters.""" - from .platforms.darwin import invalid_chars - self._invalid_chars = invalid_chars - self._reserved_names = [] - - def _load_and_check_paths(self) -> None: - """Load system and user paths, then check the current path against them.""" - from .platforms.darwin import system_paths - self._system_paths = system_paths - self._user_paths = get_user_paths() - - # Check both types - self._is_system_path = self._check_against_paths(self._system_paths) - self._is_user_path = self._check_against_paths(self._user_paths) - - -class PosixPathChecker(BasePathChecker): - """POSIX (Linux and Unix)-specific PathChecker implementation. - - Handles POSIX-compliant path validation for Linux and other Unix-like systems. - """ - - def _load_invalid_chars(self) -> None: - """Load POSIX-specific invalid characters.""" - from .platforms.posix import invalid_chars - self._invalid_chars = invalid_chars - self._reserved_names = [] - - def _load_and_check_paths(self) -> None: - """Load system and user paths, then check the current path against them.""" - from .platforms.posix import system_paths - self._system_paths = system_paths - self._user_paths = get_user_paths() - - # Check both types - self._is_system_path = self._check_against_paths(self._system_paths) - self._is_user_path = self._check_against_paths(self._user_paths) +# Platform-specific checker implementations have been moved to: +# - bad_path.platforms.checkers.windows (WindowsPathChecker) +# - bad_path.platforms.checkers.darwin (DarwinPathChecker) +# - bad_path.platforms.checkers.posix (PosixPathChecker) # ============================================================================ @@ -964,16 +811,46 @@ def _create_path_checker( """ match platform.system(): case "Windows": + from .platforms.windows.checker import ( # pylint: disable=import-outside-toplevel + WindowsPathChecker, + ) + return WindowsPathChecker( - path, raise_error, mode, system_ok, user_paths_ok, not_writeable, cwd_only + path, + raise_error, + mode, + system_ok, + user_paths_ok, + not_writeable, + cwd_only, ) case "Darwin": + from .platforms.darwin.checker import ( # pylint: disable=import-outside-toplevel + DarwinPathChecker, + ) + return DarwinPathChecker( - path, raise_error, mode, system_ok, user_paths_ok, not_writeable, cwd_only + path, + raise_error, + mode, + system_ok, + user_paths_ok, + not_writeable, + cwd_only, ) case _: # Linux and other Unix-like systems + from .platforms.posix.checker import ( # pylint: disable=import-outside-toplevel + PosixPathChecker, + ) + return PosixPathChecker( - path, raise_error, mode, system_ok, user_paths_ok, not_writeable, cwd_only + path, + raise_error, + mode, + system_ok, + user_paths_ok, + not_writeable, + cwd_only, ) diff --git a/bad_path/platforms/darwin/__init__.py b/bad_path/platforms/darwin/__init__.py new file mode 100644 index 0000000..1fc3942 --- /dev/null +++ b/bad_path/platforms/darwin/__init__.py @@ -0,0 +1,5 @@ +"""Darwin (macOS) platform-specific code. + +This module contains macOS-specific path validation logic including +system paths, invalid characters, and the DarwinPathChecker class. +""" diff --git a/bad_path/platforms/darwin/checker.py b/bad_path/platforms/darwin/checker.py new file mode 100644 index 0000000..10d9161 --- /dev/null +++ b/bad_path/platforms/darwin/checker.py @@ -0,0 +1,36 @@ +"""Darwin (macOS)-specific path checker implementation. + +This module provides the DarwinPathChecker class for validating paths on macOS systems. +""" + +from ...checker import BasePathChecker, get_user_paths + + +class DarwinPathChecker(BasePathChecker): + """Darwin (macOS)-specific PathChecker implementation. + + Handles macOS-specific path validation including restrictions on colons + in file names and macOS system directories. + """ + + def _load_invalid_chars(self) -> None: + """Load Darwin-specific invalid characters.""" + from .paths import ( # pylint: disable=import-outside-toplevel + invalid_chars, + ) + + self._invalid_chars = invalid_chars + self._reserved_names = [] + + def _load_and_check_paths(self) -> None: + """Load system and user paths, then check the current path against them.""" + from .paths import ( # pylint: disable=import-outside-toplevel + system_paths, + ) + + self._system_paths = system_paths + self._user_paths = get_user_paths() + + # Check both types + self._is_system_path = self._check_against_paths(self._system_paths) + self._is_user_path = self._check_against_paths(self._user_paths) diff --git a/bad_path/platforms/darwin.py b/bad_path/platforms/darwin/paths.py similarity index 92% rename from bad_path/platforms/darwin.py rename to bad_path/platforms/darwin/paths.py index 9b56761..6241a1c 100644 --- a/bad_path/platforms/darwin.py +++ b/bad_path/platforms/darwin/paths.py @@ -20,7 +20,8 @@ "/System", "/Library", "/private/etc", # System configuration (don't use /private to allow /private/tmp) - # /private/var subdirectories (don't use /private/var to allow /private/var/folders for temp files) + # /private/var subdirectories (don't use /private/var to allow + # /private/var/folders for temp files) "/private/var/root", # Root user's home directory "/private/var/db", # System databases "/private/var/log", # System logs @@ -43,5 +44,5 @@ # The null byte (\0) and colon (:) are forbidden in file names. invalid_chars = [ "\0", # Null byte - strictly forbidden in POSIX - ":", # Colon - problematic in macOS (was path separator in legacy Mac OS) + ":", # Colon - problematic in macOS (was path separator in legacy Mac OS) ] diff --git a/bad_path/platforms/posix/__init__.py b/bad_path/platforms/posix/__init__.py new file mode 100644 index 0000000..16ff237 --- /dev/null +++ b/bad_path/platforms/posix/__init__.py @@ -0,0 +1,5 @@ +"""POSIX (Linux and Unix) platform-specific code. + +This module contains POSIX-specific path validation logic including +system paths, invalid characters, and the PosixPathChecker class. +""" diff --git a/bad_path/platforms/posix/checker.py b/bad_path/platforms/posix/checker.py new file mode 100644 index 0000000..dee00a8 --- /dev/null +++ b/bad_path/platforms/posix/checker.py @@ -0,0 +1,35 @@ +"""POSIX (Linux and Unix)-specific path checker implementation. + +This module provides the PosixPathChecker class for validating paths on POSIX-compliant systems. +""" + +from ...checker import BasePathChecker, get_user_paths + + +class PosixPathChecker(BasePathChecker): + """POSIX (Linux and Unix)-specific PathChecker implementation. + + Handles POSIX-compliant path validation for Linux and other Unix-like systems. + """ + + def _load_invalid_chars(self) -> None: + """Load POSIX-specific invalid characters.""" + from .paths import ( # pylint: disable=import-outside-toplevel + invalid_chars, + ) + + self._invalid_chars = invalid_chars + self._reserved_names = [] + + def _load_and_check_paths(self) -> None: + """Load system and user paths, then check the current path against them.""" + from .paths import ( # pylint: disable=import-outside-toplevel + system_paths, + ) + + self._system_paths = system_paths + self._user_paths = get_user_paths() + + # Check both types + self._is_system_path = self._check_against_paths(self._system_paths) + self._is_user_path = self._check_against_paths(self._user_paths) diff --git a/bad_path/platforms/posix.py b/bad_path/platforms/posix/paths.py similarity index 100% rename from bad_path/platforms/posix.py rename to bad_path/platforms/posix/paths.py diff --git a/bad_path/platforms/windows/__init__.py b/bad_path/platforms/windows/__init__.py new file mode 100644 index 0000000..94a3b67 --- /dev/null +++ b/bad_path/platforms/windows/__init__.py @@ -0,0 +1,5 @@ +"""Windows platform-specific code. + +This module contains Windows-specific path validation logic including +system paths, invalid characters, reserved names, and the WindowsPathChecker class. +""" diff --git a/bad_path/platforms/windows/checker.py b/bad_path/platforms/windows/checker.py new file mode 100644 index 0000000..271bec9 --- /dev/null +++ b/bad_path/platforms/windows/checker.py @@ -0,0 +1,143 @@ +"""Windows-specific path checker implementation. + +This module provides the WindowsPathChecker class for validating paths on Windows systems. +""" + +from pathlib import Path + +from ...checker import BasePathChecker, get_user_paths + + +class WindowsPathChecker(BasePathChecker): + """Windows-specific PathChecker implementation. + + Handles Windows-specific path validation including drive letters, reserved names, + and Windows-specific invalid characters. + """ + + def _load_invalid_chars(self) -> None: + """Load Windows-specific invalid characters and reserved names.""" + from .paths import ( # pylint: disable=import-outside-toplevel + invalid_chars, + reserved_names, + ) + + self._invalid_chars = invalid_chars + self._reserved_names = reserved_names + + def _load_and_check_paths(self) -> None: + """Load system and user paths, then check the current path against them.""" + from .paths import ( # pylint: disable=import-outside-toplevel + system_paths, + ) + + self._system_paths = system_paths + self._user_paths = get_user_paths() + + # Check both types + self._is_system_path = self._check_against_paths(self._system_paths) + self._is_user_path = self._check_against_paths(self._user_paths) + + def _check_cwd_traversal(self, path_obj: Path | None = None) -> bool: + """Check if a path traverses outside the current working directory. + + Windows-specific implementation with case-insensitive comparison. + + Keyword Parameters: + path_obj (Path | None): + Optional Path object to check. If not provided, uses self._path_obj. + Defaults to None. + + Returns: + (bool): + True if the path is outside CWD (dangerous), False otherwise. + """ + if path_obj is None: + path_obj = self._path_obj + try: + cwd = Path.cwd().resolve() + + # Check if path equals CWD (handles "." case) + # Use case-insensitive string comparison for Windows + if str(path_obj).lower() == str(cwd).lower(): + return False # Path is CWD itself (safe) + + # Also try samefile() if paths exist (handles symlinks, etc.) + try: + if path_obj.exists() and cwd.exists() and path_obj.samefile(cwd): + return False # Same file/directory (safe) + except (OSError, ValueError, AttributeError): + # samefile() not available or failed, continue with relative_to + pass + + # Try to express path_obj relative to cwd + # If this succeeds, the path is within CWD + path_obj.relative_to(cwd) + return False # Path is within CWD (safe) + except ValueError: + # relative_to raised ValueError, so path is outside CWD + return True # Path is outside CWD (dangerous) + except (OSError, RuntimeError): + # If other resolution fails, treat as dangerous + return True + + def _check_invalid_chars(self, path_str: str | None = None) -> bool: + """Check for Windows-specific invalid characters. + + Includes special handling for: + - Drive letter colons (C:, D:, etc.) + - Reserved names (CON, PRN, AUX, etc.) + - Paths ending with space or period + + Keyword Parameters: + path_str (str | None): + Optional path string to check. If not provided, uses self._path. + Defaults to None. + + Returns: + (bool): + True if the path contains invalid characters, False otherwise. + """ + if path_str is None: + path_str = str(self._path_obj) + + # Check for invalid characters + for char in self._invalid_chars: + if char in path_str: + # Special handling for colon on Windows (valid in drive letters like C:) + if char == ":": + # Check if colon is part of a drive letter (e.g., C:, D:) + # Valid pattern: single letter followed by colon at start of path + if ( + len(path_str) >= 2 + and path_str[1] == ":" + and path_str[0].isalpha() + ): + # This is a valid drive letter if it's the only colon + if path_str.count(":") == 1: + continue # This is a valid drive letter colon + return True + + # Check for reserved names (case-insensitive) + # Extract the filename from the path using string operations + # to avoid Path() issues with invalid characters + # Split by both forward slash and backslash + path_parts = path_str.replace("\\", "/").split("/") + if path_parts: + filename = path_parts[-1] + + # Extract name without extension + if "." in filename: + name_without_ext = filename.rsplit(".", 1)[0].upper() + else: + name_without_ext = filename.upper() + + # Check if the name (without extension) is a reserved name + if name_without_ext in self._reserved_names: + return True + + # Check if filename ends with space or period (invalid in Windows) + if filename and (filename.endswith(" ") or filename.endswith(".")): + return True + + return False diff --git a/bad_path/platforms/windows.py b/bad_path/platforms/windows/paths.py similarity index 81% rename from bad_path/platforms/windows.py rename to bad_path/platforms/windows/paths.py index 3984364..c4c4db8 100644 --- a/bad_path/platforms/windows.py +++ b/bad_path/platforms/windows/paths.py @@ -30,7 +30,9 @@ "|", # Pipe "?", # Question mark "*", # Asterisk -] + [chr(i) for i in range(32)] # Control characters 0-31 +] + [ + chr(i) for i in range(32) +] # Control characters 0-31 # Note: Forward slash (/) and backslash (\) are path separators in Windows. # They are technically invalid within individual filename components, but we don't @@ -40,7 +42,26 @@ # Reserved file names in Windows (case-insensitive) # These names cannot be used as file names, even with extensions reserved_names = [ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", ]