#!/usr/bin/env python3 -B # coding=utf-8 """ Copyright (C) 2022-2024 Plato Mavropoulos """ import os import re import shutil import stat import sys from pathlib import Path, PurePath from typing import Callable, Final from biosutilities.common.system import system_platform from biosutilities.common.texts import to_string MAX_WIN_COMP_LEN: Final[int] = 255 def safe_name(in_name: str) -> str: """ Fix illegal/reserved Windows characters Can also be used to nuke dangerous paths """ name_repr: str = repr(in_name).strip("'") return re.sub(r'[\\/:"*?<>|]+', '_', name_repr) def safe_path(base_path: str, user_paths: str | list | tuple) -> str: """ Check and attempt to fix illegal/unsafe OS path traversals """ # Convert base path to absolute path base_path = real_path(in_path=base_path) # Merge user path(s) to string with OS separators user_path: str = to_string(in_object=user_paths, sep_char=os.sep) # Create target path from base + requested user path target_path: str = norm_path(base_path=base_path, user_path=user_path) # Check if target path is OS illegal/unsafe if is_safe_path(base_path=base_path, target_path=target_path): return target_path # Re-create target path from base + leveled/safe illegal "path" (now file) nuked_path: str = norm_path(base_path=base_path, user_path=safe_name(in_name=user_path)) # Check if illegal path leveling worked if is_safe_path(base_path=base_path, target_path=nuked_path): return nuked_path # Still illegal, raise exception to halt execution raise OSError(f'Encountered illegal path traversal: {user_path}') def is_safe_path(base_path: str, target_path: str) -> bool: """ Check for illegal/unsafe OS path traversal """ base_path = real_path(in_path=base_path) target_path = real_path(in_path=target_path) common_path: str = os.path.commonpath((base_path, target_path)) return base_path == common_path def norm_path(base_path: str, user_path: str) -> str: """ Create normalized base path + OS separator + user path """ return os.path.normpath(base_path + os.sep + user_path) def real_path(in_path: str) -> str: """ Get absolute path, resolving any symlinks """ return os.path.realpath(in_path) def agnostic_path(in_path: str) -> PurePath: """ Get Windows/Posix OS-agnostic path """ return PurePath(in_path.replace('\\', os.sep)) def path_parent(in_path: str) -> Path: """ Get absolute parent of path """ return Path(in_path).parent.absolute() def path_name(in_path: str, limit: bool = False) -> str: """ Get final path component, with suffix """ comp_name: str = PurePath(in_path).name is_win: bool = system_platform()[1] if limit and is_win: comp_name = comp_name[:MAX_WIN_COMP_LEN - len(extract_suffix())] return comp_name def path_stem(in_path: str) -> str: """ Get final path component, w/o suffix """ return PurePath(in_path).stem def path_size(in_path: str) -> int: """ Get path size (bytes) """ return os.stat(in_path).st_size def path_suffixes(in_path: str) -> list[str]: """ Get list of path file extensions """ return PurePath(in_path).suffixes or [''] def make_dirs(in_path: str, parents: bool = True, exist_ok: bool = True, delete: bool = False): """ Create folder(s), controlling parents, existence and prior deletion """ if delete: delete_dirs(in_path=in_path) Path.mkdir(Path(in_path), parents=parents, exist_ok=exist_ok) def delete_dirs(in_path: str) -> None: """ Delete folder(s), if present """ if is_dir(in_path=in_path): shutil.rmtree(in_path, onerror=clear_readonly_callback) # pylint: disable=deprecated-argument def delete_file(in_path: str) -> None: """ Delete file, if present """ if is_file(in_path=in_path): clear_readonly(in_path=in_path) os.remove(in_path) def rename_file(in_path: str, in_dest: str) -> None: """ Rename file with path or name destination, if present """ if is_file(in_path=in_path): clear_readonly(in_path=in_path) if is_file(in_path=in_dest, allow_broken_links=True): clear_readonly(in_path=in_dest) out_path: str = in_dest else: out_path = os.path.join(path_parent(in_path=in_path), in_dest) os.replace(in_path, out_path) def copy_file(in_path: str, out_path: str, metadata: bool = False) -> None: """ Copy file to path with or w/o metadata """ if metadata: shutil.copy2(in_path, out_path) else: shutil.copy(in_path, out_path) def clear_readonly(in_path: str) -> None: """ Clear read-only file attribute """ os.chmod(in_path, stat.S_IWRITE) def clear_readonly_callback(in_func: Callable, in_path: str, _) -> None: """ Clear read-only file attribute (on shutil.rmtree error) """ clear_readonly(in_path=in_path) in_func(in_path) def path_files(in_path: str, follow_links: bool = False, root_only: bool = False) -> list[str]: """ Walk path to get all files """ file_paths: list[str] = [] for root_path, _, file_names in os.walk(in_path, followlinks=follow_links): for file_name in file_names: file_path: str = os.path.abspath(os.path.join(root_path, file_name)) if is_file(in_path=file_path): file_paths.append(file_path) if root_only: break return file_paths def is_dir(in_path: str) -> bool: """ Check if path is a directory """ return Path(in_path).is_dir() def is_file(in_path: str, allow_broken_links: bool = False) -> bool: """ Check if path is a regural file or symlink (valid or broken) """ in_path_abs: str = os.path.abspath(in_path) if os.path.lexists(in_path_abs): if not is_dir(in_path=in_path_abs): if allow_broken_links: return os.path.isfile(in_path_abs) or os.path.islink(in_path_abs) return os.path.isfile(in_path_abs) return False def is_access(in_path: str, access_mode: int = os.R_OK, follow_links: bool = False) -> bool: """ Check if path is accessible """ if not follow_links and os.access not in os.supports_follow_symlinks: follow_links = True return os.access(in_path, access_mode, follow_symlinks=follow_links) def is_empty_dir(in_path: str, follow_links: bool = False) -> bool: """ Check if directory is empty (file-wise) """ for _, _, filenames in os.walk(in_path, followlinks=follow_links): if filenames: return False return True def extract_suffix() -> str: """ Set utility extraction stem """ return '_extracted' def extract_folder(in_path: str, suffix: str = extract_suffix()) -> str: """ Get utility extraction directory """ return f'{in_path}{suffix}' def project_root() -> str: """ Get project root directory """ return real_path(in_path=str(Path(__file__).parent.parent)) def runtime_root() -> str: """ Get runtime root directory """ if getattr(sys, 'frozen', False): root: str = str(Path(sys.executable).parent) else: root = project_root() return real_path(in_path=root)