diff --git a/README.md b/README.md index 124b807..66e56b9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,28 @@ You must also install VeraCrypt and ensure the VeraCrypt CLI is available on you Mount operations may require administrator or sudo permissions depending on your OS and system configuration. +## Privileges (Linux/macOS) + +By default, on Linux/macOS the wrapper prefixes VeraCrypt commands with `sudo`, preserving backward-compatible behavior. Two constructor parameters let you control this: + +- `use_sudo` (default `True`): whether to prefix commands with `sudo`. Mounting and dismounting volumes generally require root, so keep `sudo` (or run the process elevated) for those. **Creating a file container does not require root**, so pass `use_sudo=False` for least-privilege, non-interactive volume creation. +- `sudo_non_interactive` (default `False`): when `use_sudo=True`, invoke `sudo -n` so it never prompts for a password. This avoids `sudo`'s interactive password prompt consuming the volume password from `stdin` (which would otherwise cause the call to fail in headless/non-interactive contexts). + +```python +from veracrypt import VeraCrypt, FileSystem + +# Least-privilege, non-interactive file-container creation (no root needed): +vc = VeraCrypt(use_sudo=False) +vc.create_volume("/home/user/secure/test.vc", "SecretPassword", 5 * 1024 * 1024, filesystem=FileSystem.EXFAT) + +# Mounting needs root; keep sudo but never prompt (requires a passwordless sudo rule +# or an already-elevated process): +vc_mount = VeraCrypt(sudo_non_interactive=True) +vc_mount.mount_volume("/home/user/secure/test.vc", "SecretPassword", "/mnt/veracrypt") +``` + +These parameters are ignored on Windows. + ## Usage ```python diff --git a/build/lib/veracrypt/__about__.py b/build/lib/veracrypt/__about__.py new file mode 100644 index 0000000..23b2bfe --- /dev/null +++ b/build/lib/veracrypt/__about__.py @@ -0,0 +1,7 @@ +__title__ = "python-veracrypt" +__description__ = "A cross-platform Python wrapper for the VeraCrypt CLI." +__url__ = "https://github.com/srichs/python-veracrypt" +__version__ = "0.1.0" +__author__ = "srichs" +__author_email__ = "srichs@pm.me" +__license__ = "GNU GPL v3.0" diff --git a/build/lib/veracrypt/__init__.py b/build/lib/veracrypt/__init__.py new file mode 100644 index 0000000..20bd9c5 --- /dev/null +++ b/build/lib/veracrypt/__init__.py @@ -0,0 +1,3 @@ +from .veracrypt import Encryption, FileSystem, Hash, VeraCrypt, VeraCryptError + +__all__ = ["Encryption", "FileSystem", "Hash", "VeraCrypt", "VeraCryptError"] diff --git a/build/lib/veracrypt/veracrypt.py b/build/lib/veracrypt/veracrypt.py new file mode 100644 index 0000000..d1b6e58 --- /dev/null +++ b/build/lib/veracrypt/veracrypt.py @@ -0,0 +1,650 @@ +"""Python wrapper around the VeraCrypt CLI.""" + +import logging +import os +import platform +import subprocess +from enum import Enum +from typing import List, Optional, Tuple, Type + + +class Encryption(Enum): + """Supported VeraCrypt encryption algorithms.""" + + AES = "AES" + SERPENT = "Serpent" + TWOFISH = "Twofish" + CAMELLIA = "Camellia" + KUZNYECHIK = "Kuznyechik" + AES_TWOFISH = "AES(Twofish)" + TWOFISH_SERPENT = "Twofish(Serpent)" + SERPENT_AES = "Serpent(AES)" + SERPENT_TWOFISH_AES = "Serpent(Twofish(AES))" + AES_SERPENT = "AES(Serpent)" + KUZNYECHIK_CAMELLIA = "Kuznyechik(Camellia)" + CAMELLIA_KUZNYECHIK = "Camellia(Kuznyechik)" + + +class Hash(Enum): + """Supported VeraCrypt hash algorithms.""" + + SHA256 = "sha-256" + SHA512 = "sha-512" + WHIRLPOOL = "whirlpool" + BLAKE2S = "blake2s" + RIPEMD160 = "ripemd-160" + STREEBOG = "streebog" + + +class FileSystem(Enum): + """Supported filesystem formats for newly created volumes.""" + + NONE = "None" + FAT = "FAT" + EXFAT = "exFAT" + NTFS = "NTFS" + EXT2 = "ext2" + EXT3 = "ext3" + EXT4 = "ext4" + HFS = "HFS" + APFS = "APFS" + + +class VeraCryptError(RuntimeError): + """Raised when VeraCrypt operations fail.""" + + +class VeraCrypt(object): + """Cross-platform wrapper for core VeraCrypt CLI operations. + + The class wraps the VeraCrypt CLI to create, mount, and dismount volumes on Windows, + macOS, and Linux systems. Windows uses ``/param value`` arguments and separates the + mount/dismount tool (``VeraCrypt.exe``) from the format tool + (``VeraCrypt Format.exe``), + while Linux/macOS use ``--param value`` arguments against a single executable. + + Extra CLI options can be supplied to the public methods, but invalid options will + result in a VeraCrypt CLI error. + + The :meth:`command` method allows arbitrary command execution. On Windows, set + ``windows_program="VeraCrypt Format.exe"`` to target the volume creation CLI. + + **WARNING:** On Windows the VeraCrypt CLI does not accept passwords from stdin. The + password will be present in the subprocess arguments for the duration of the call. + The returned ``CompletedProcess.args`` has the password masked for logging safety. + + :param log_level: Logging level to use. Defaults to ``logging.ERROR``. + :param log_fmt: Logging format string. + :param log_datefmt: Date format string used in log messages. + :param veracrypt_path: Path to the VeraCrypt executable. On Windows, this should be + the directory containing the executables. On macOS/Linux, this should be the + full path to the VeraCrypt binary. If ``None``, a platform-specific default is + discovered. + :param use_sudo: On Linux/macOS, whether to prefix VeraCrypt commands with ``sudo``. + Defaults to ``True`` to preserve backward-compatible behavior. Mounting and + dismounting volumes typically require root, but file-container creation does + not, so pass ``use_sudo=False`` for least-privilege, non-interactive volume + creation. Ignored on Windows. + :param sudo_non_interactive: On Linux/macOS, when ``use_sudo`` is ``True``, whether + to invoke ``sudo`` with ``-n`` (non-interactive) so it never prompts for a + password. Defaults to ``False``. This avoids ``sudo``'s password prompt + consuming the volume password from stdin. Ignored on Windows or when + ``use_sudo`` is ``False``. + """ + + def __init__( + self, + log_level: Optional[int] = logging.ERROR, + log_fmt: Optional[str] = "%(levelname)s:%(module)s:%(funcName)s:%(message)s", + log_datefmt: Optional[str] = "%Y-%m-%d %H:%M:%S", + veracrypt_path: Optional[str] = None, + use_sudo: bool = True, + sudo_non_interactive: bool = False, + ): + if log_fmt is None: + log_fmt = "%(levelname)s:%(module)s:%(funcName)s:%(message)s" + if log_datefmt is None: + log_datefmt = "%Y-%m-%d %H:%M:%S" + logging.basicConfig(level=log_level, format=log_fmt, datefmt=log_datefmt) + self.logger = logging.getLogger("veracrypt.py") + self.os_name = platform.system() + self.veracrypt_path = veracrypt_path or self._default_path() + self.use_sudo = use_sudo + self.sudo_non_interactive = sudo_non_interactive + self.logger.info("Object initialized") + + def _privilege_prefix(self) -> List[str]: + """Return the privilege-escalation prefix for Linux/macOS commands. + + :return: ``[]`` when ``use_sudo`` is ``False``, ``["sudo", "-n"]`` when + ``sudo_non_interactive`` is ``True``, otherwise ``["sudo"]``. + """ + if not self.use_sudo: + return [] + return ["sudo", "-n"] if self.sudo_non_interactive else ["sudo"] + + def _validate_options(self, options: Optional[List[str]], context: str) -> None: + """Validate CLI options passed into public methods.""" + if options is None: + return + if not isinstance(options, list) or not all( + isinstance(item, str) for item in options + ): + raise ValueError( + f"{context} options must be a list of strings when provided." + ) + + def _validate_keyfiles(self, keyfiles: Optional[List[str]], context: str) -> None: + """Validate keyfile paths passed into public methods.""" + if keyfiles is None: + return + if not isinstance(keyfiles, list) or not all( + isinstance(item, str) for item in keyfiles + ): + raise ValueError( + f"{context} keyfiles must be a list of strings when provided." + ) + for keyfile in keyfiles: + self._check_path(keyfile) + + @staticmethod + def _validate_size(size: int) -> None: + """Validate volume size is a positive integer.""" + if not isinstance(size, int) or size <= 0: + raise ValueError("Volume size must be a positive integer.") + + @staticmethod + def _validate_enum(value: Enum, enum_cls: Type[Enum], name: str) -> None: + """Validate that an argument is an instance of a specific enum class.""" + if not isinstance(value, enum_cls): + raise ValueError(f"{name} must be an instance of {enum_cls.__name__}.") + + @staticmethod + def _validate_volume_parent_dir(volume_path: str) -> None: + """Ensure the parent directory for a volume path exists.""" + parent_dir = os.path.dirname(volume_path) or "." + if not os.path.exists(parent_dir): + raise VeraCryptError( + f"The parent directory for {volume_path} does not exist." + ) + + @staticmethod + def _mask_password_in_args(args: List[str], password: str, index: int) -> None: + """Safely mask a password in a command args list.""" + if 0 <= index < len(args): + args[index] = "*" * len(password) + + def _default_path(self) -> str: + """Return the default VeraCrypt CLI path for the current platform.""" + self.logger.debug("Getting default path") + + if self.os_name == "Windows": + path = os.path.join("C:\\", "Program Files", "VeraCrypt") + path1 = os.path.join(path, "VeraCrypt.exe") + path2 = os.path.join(path, "VeraCrypt Format.exe") + + if not os.path.exists(path1): + raise VeraCryptError(f"VeraCrypt.exe not found at {path1}") + if not os.path.exists(path2): + raise VeraCryptError(f'"VeraCrypt Format.exe" not found at {path2}') + elif self.os_name == "Darwin": # macOS + path = os.path.join( + "/", "Applications", "VeraCrypt.app", "Contents", "MacOS", "VeraCrypt" + ) + elif self.os_name == "Linux": + path = os.path.join("/", "usr", "bin", "veracrypt") + else: + raise VeraCryptError("Unsupported Operating System") + + self._check_path(path) + self.logger.info(f"VeraCrypt program path found at {path}") + return path + + def _check_path(self, path: str) -> bool: + """Validate that a path exists. + + :param path: Filesystem path to validate. + :raises VeraCryptError: If the path does not exist. + :return: ``True`` when the path exists. + """ + if os.path.exists(path): + self.logger.debug(f"Path {path} exists") + return True + else: + raise VeraCryptError(f"The path {path} does not exist") + + def mount_volume( + self, + volume_path: str, + password: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> subprocess.CompletedProcess: + """Mount a VeraCrypt volume. + + :param volume_path: Path to the volume file to mount. + :param password: Password for the volume. + :param mount_point: Target mount point (drive letter on Windows). + :param options: Additional CLI options. Options differ by platform. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Mounting volume") + self._validate_options(options, "mount_volume") + self._check_path(volume_path) + if self.os_name != "Windows" and mount_point: + self._check_path(mount_point) + + if self.os_name == "Windows": + cmd = self._mount_win(volume_path, password, mount_point, options) + else: + cmd = self._mount_nix(volume_path, mount_point, options) + self.logger.debug(f"Command created: {cmd}") + + try: + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._mask_password_in_args(result.args, password, 4) + else: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error mounting volume: {e.stderr}") from e + + def _mount_win( + self, + volume_path: str, + password: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Windows CLI command for mounting a volume.""" + self.logger.debug("Mounting volume on Windows") + cmd = [ + os.path.join(self.veracrypt_path, "VeraCrypt.exe"), + "/volume", + volume_path, + "/password", + password, + ] + + if mount_point: + cmd += ["/letter", mount_point] + + if options: + cmd += options + cmd += ["/quit", "/silent", "/force"] + self.logger.debug("Mount command generated") + return cmd + + def _mount_nix( + self, + volume_path: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Linux/macOS CLI command for mounting a volume.""" + self.logger.debug("Mounting volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--mount", + volume_path, + ] + + if mount_point: + cmd += [mount_point] + + if options: + cmd += options + cmd += ["--stdin", "--force"] + self.logger.debug("Mount command generated") + return cmd + + def dismount_volume( + self, target: str = "all", options: Optional[List[str]] = None + ) -> subprocess.CompletedProcess: + """Dismount a VeraCrypt volume or all mounted volumes. + + :param target: Mount point to dismount. Use ``"all"`` to dismount all volumes. + :param options: Additional CLI options. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Dismounting volume") + self._validate_options(options, "dismount_volume") + + if self.os_name == "Windows": + cmd = self._dismount_win(target, options) + else: + cmd = self._dismount_nix(target, options) + self.logger.debug(f"Command created: {cmd}") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error dismounting volume: {e.stderr}") from e + + def _dismount_win( + self, target: str = "all", options: Optional[List[str]] = None + ) -> List[str]: + """Build the Windows CLI command for dismounting volumes.""" + self.logger.debug("Dismounting volume on Windows") + cmd = [os.path.join(self.veracrypt_path, "VeraCrypt.exe"), "/dismount"] + + if target != "all": + cmd.append(target) + + if options: + cmd += options + cmd += ["/quit", "/silent", "/force"] + self.logger.debug("Dismount command generated") + return cmd + + def _dismount_nix( + self, target: str = "all", options: Optional[List[str]] = None + ) -> List[str]: + """Build the Linux/macOS CLI command for dismounting volumes.""" + self.logger.debug("Dismounting volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--unmount", + ] + + if target != "all": + self._check_path(target) + cmd.append(target) + + if options: + cmd += options + self.logger.debug("Dismount command generated") + return cmd + + def create_volume( + self, + volume_path: str, + password: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + hidden: bool = False, + options: Optional[List[str]] = None, + ) -> subprocess.CompletedProcess: + """Create a new VeraCrypt volume. + + :param volume_path: Destination path for the volume file. + :param password: Password for the volume. + :param size: Volume size in bytes. + :param encryption: Encryption algorithm to use. + :param hash_alg: Hash algorithm to use. + :param filesystem: Filesystem format to apply. + :param keyfiles: Optional keyfiles to include in encryption. + :param hidden: Whether to create a hidden volume. + :param options: Additional CLI options. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Creating volume") + self._validate_options(options, "create_volume") + self._validate_keyfiles(keyfiles, "create_volume") + self._validate_size(size) + self._validate_enum(encryption, Encryption, "encryption") + self._validate_enum(hash_alg, Hash, "hash_alg") + self._validate_enum(filesystem, FileSystem, "filesystem") + if self.os_name == "Linux": + self._validate_volume_parent_dir(volume_path) + + if self.os_name == "Windows": + cmd = self._create_win( + volume_path, + password, + size, + encryption, + hash_alg, + filesystem, + keyfiles, + options, + ) + else: + if self.os_name == "Darwin": + if not os.path.exists(volume_path): + with open(volume_path, "w"): + pass + cmd = self._create_nix( + volume_path, + size, + encryption, + hash_alg, + filesystem, + keyfiles, + hidden, + options, + ) + self.logger.debug(f"Command created: {cmd}") + + try: + result = None + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._mask_password_in_args(result.args, password, 4) + else: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error creating volume: {e.stderr}") from e + + def _create_win( + self, + volume_path: str, + password: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Windows CLI command for creating a volume.""" + self.logger.debug("Creating volume on Windows") + cmd = [ + os.path.join(self.veracrypt_path, "VeraCrypt Format.exe"), + "/create", + volume_path, + "/password", + password, + "/size", + f"{size}", + "/encryption", + encryption.value, + "/hash", + hash_alg.value, + "/filesystem", + filesystem.value, + ] + + if keyfiles: + for keyfile in keyfiles: + cmd += ["/keyfile", keyfile] + + if options: + cmd += options + cmd += ["/protectMemory", "/quick", "/silent", "/force"] + self.logger.debug("Create command generated") + return cmd + + def _create_nix( + self, + volume_path: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + hidden: bool = False, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Linux/macOS CLI command for creating a volume.""" + self.logger.debug("Creating volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--create", + volume_path, + "--size", + f"{size}", + "--encryption", + encryption.value, + "--hash", + hash_alg.value, + "--filesystem", + filesystem.value, + ] + + if keyfiles: + for keyfile in keyfiles: + cmd += ["--keyfiles", keyfile] + + if hidden: + cmd += ["--volume-type", "hidden"] + + if options: + cmd += options + cmd += ["--random-source", "/dev/urandom", "--stdin", "--quick", "--force"] + self.logger.debug("Create command generated") + return cmd + + def command( + self, + options: Optional[List[str]] = None, + windows_program: str = "VeraCrypt.exe", + ) -> subprocess.CompletedProcess: + """Call the VeraCrypt CLI with custom options. + + :param options: Options to pass to the VeraCrypt CLI. + :param windows_program: Windows-only program name to invoke. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Calling custom command") + self._validate_options(options, "command") + if self.os_name == "Windows": + cmd = self._custom_win(options, windows_program) + password, index = self._get_password(cmd) + else: + password, index = self._get_password(options) + cmd = self._custom_nix(options) + self.logger.debug(f"Command created: {cmd}") + + try: + result = None + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + if password is not None: + self.logger.debug("Sanitizing password") + self._mask_password_in_args(result.args, password, index) + else: + if password is not None: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + else: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error calling custom command: {e.stderr}") from e + + def _custom_win( + self, + options: Optional[List[str]] = None, + windows_program: str = "VeraCrypt.exe", + ) -> List[str]: + """Build a Windows CLI command using an arbitrary VeraCrypt executable.""" + self.logger.debug("Calling custom command on Windows") + cmd = [os.path.join(self.veracrypt_path, windows_program)] + + if options: + cmd += options + self.logger.debug("Custom command generated") + return cmd + + def _custom_nix(self, options: Optional[List[str]] = None) -> List[str]: + """Build a Linux/macOS CLI command using provided options.""" + self.logger.debug("Calling custom command on Linux/MacOS") + options_list = list(options) if options else [] + password, p_index = self._get_password(options_list) + cmd = [*self._privilege_prefix(), self.veracrypt_path] + + if password and options_list: + self.logger.debug("Removing password from command line options") + del options_list[p_index - 1 : p_index + 1] + + if options_list: + cmd += options_list + + if password: + if "--stdin" not in options_list: + cmd += ["--stdin"] + self.logger.debug("Custom command generated") + return cmd + + def _get_password(self, cmd: Optional[List[str]]) -> Tuple[Optional[str], int]: + """Extract a password argument from a command list. + + :param cmd: Command list or ``None``. + :return: Tuple of password value and index where it was found. + """ + if not cmd: + return None, -1 + pword_option = "--password" + if self.os_name == "Windows": + pword_option = "/password" + + try: + option_index = cmd.index(pword_option) + except ValueError: + return None, -1 + + try: + index = option_index + 1 + pword = cmd[index] + if pword == "": + raise ValueError("Password option provided without a value.") + except IndexError as exc: + raise ValueError("Password option provided without a value.") from exc + return pword, index diff --git a/pyproject.toml b/pyproject.toml index a1c7c2e..78c9c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-veracrypt" -version = "0.1.2" +version = "0.2.0" description = "A cross-platform Python wrapper for the VeraCrypt CLI." readme = "README.md" requires-python = ">=3.11" diff --git a/src/veracrypt/__about__.py b/src/veracrypt/__about__.py index 23b2bfe..2b12d2e 100644 --- a/src/veracrypt/__about__.py +++ b/src/veracrypt/__about__.py @@ -1,7 +1,7 @@ __title__ = "python-veracrypt" __description__ = "A cross-platform Python wrapper for the VeraCrypt CLI." __url__ = "https://github.com/srichs/python-veracrypt" -__version__ = "0.1.0" +__version__ = "0.2.0" __author__ = "srichs" __author_email__ = "srichs@pm.me" __license__ = "GNU GPL v3.0" diff --git a/src/veracrypt/veracrypt.py b/src/veracrypt/veracrypt.py index f16d6f4..d1b6e58 100644 --- a/src/veracrypt/veracrypt.py +++ b/src/veracrypt/veracrypt.py @@ -80,6 +80,16 @@ class VeraCrypt(object): the directory containing the executables. On macOS/Linux, this should be the full path to the VeraCrypt binary. If ``None``, a platform-specific default is discovered. + :param use_sudo: On Linux/macOS, whether to prefix VeraCrypt commands with ``sudo``. + Defaults to ``True`` to preserve backward-compatible behavior. Mounting and + dismounting volumes typically require root, but file-container creation does + not, so pass ``use_sudo=False`` for least-privilege, non-interactive volume + creation. Ignored on Windows. + :param sudo_non_interactive: On Linux/macOS, when ``use_sudo`` is ``True``, whether + to invoke ``sudo`` with ``-n`` (non-interactive) so it never prompts for a + password. Defaults to ``False``. This avoids ``sudo``'s password prompt + consuming the volume password from stdin. Ignored on Windows or when + ``use_sudo`` is ``False``. """ def __init__( @@ -88,6 +98,8 @@ def __init__( log_fmt: Optional[str] = "%(levelname)s:%(module)s:%(funcName)s:%(message)s", log_datefmt: Optional[str] = "%Y-%m-%d %H:%M:%S", veracrypt_path: Optional[str] = None, + use_sudo: bool = True, + sudo_non_interactive: bool = False, ): if log_fmt is None: log_fmt = "%(levelname)s:%(module)s:%(funcName)s:%(message)s" @@ -97,8 +109,20 @@ def __init__( self.logger = logging.getLogger("veracrypt.py") self.os_name = platform.system() self.veracrypt_path = veracrypt_path or self._default_path() + self.use_sudo = use_sudo + self.sudo_non_interactive = sudo_non_interactive self.logger.info("Object initialized") + def _privilege_prefix(self) -> List[str]: + """Return the privilege-escalation prefix for Linux/macOS commands. + + :return: ``[]`` when ``use_sudo`` is ``False``, ``["sudo", "-n"]`` when + ``sudo_non_interactive`` is ``True``, otherwise ``["sudo"]``. + """ + if not self.use_sudo: + return [] + return ["sudo", "-n"] if self.sudo_non_interactive else ["sudo"] + def _validate_options(self, options: Optional[List[str]], context: str) -> None: """Validate CLI options passed into public methods.""" if options is None: @@ -270,7 +294,7 @@ def _mount_nix( """Build the Linux/macOS CLI command for mounting a volume.""" self.logger.debug("Mounting volume on Linux/MacOS") cmd = [ - "sudo", + *self._privilege_prefix(), self.veracrypt_path, "--text", "--non-interactive", @@ -335,7 +359,13 @@ def _dismount_nix( ) -> List[str]: """Build the Linux/macOS CLI command for dismounting volumes.""" self.logger.debug("Dismounting volume on Linux/MacOS") - cmd = ["sudo", self.veracrypt_path, "--text", "--non-interactive", "--unmount"] + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--unmount", + ] if target != "all": self._check_path(target) @@ -482,7 +512,7 @@ def _create_nix( """Build the Linux/macOS CLI command for creating a volume.""" self.logger.debug("Creating volume on Linux/MacOS") cmd = [ - "sudo", + *self._privilege_prefix(), self.veracrypt_path, "--text", "--non-interactive", @@ -578,7 +608,7 @@ def _custom_nix(self, options: Optional[List[str]] = None) -> List[str]: self.logger.debug("Calling custom command on Linux/MacOS") options_list = list(options) if options else [] password, p_index = self._get_password(options_list) - cmd = ["sudo", self.veracrypt_path] + cmd = [*self._privilege_prefix(), self.veracrypt_path] if password and options_list: self.logger.debug("Removing password from command line options") diff --git a/tests/test_veracrypt.py b/tests/test_veracrypt.py index 6538cdb..a798ea6 100644 --- a/tests/test_veracrypt.py +++ b/tests/test_veracrypt.py @@ -189,6 +189,78 @@ def test_create_nix_hidden_volume(self): self.assertIn("sha-256", cmd) self.assertIn("ext4", cmd) + def test_privilege_prefix_default_uses_sudo(self): + self.assertEqual(self.veracrypt._privilege_prefix(), ["sudo"]) + + def test_privilege_prefix_no_sudo_is_empty(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + self.assertEqual(vc._privilege_prefix(), []) + + def test_privilege_prefix_non_interactive_adds_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + self.assertEqual(vc._privilege_prefix(), ["sudo", "-n"]) + + def test_privilege_prefix_no_sudo_ignores_non_interactive(self): + vc = VeraCrypt( + veracrypt_path="/usr/bin/veracrypt", + use_sudo=False, + sudo_non_interactive=True, + ) + self.assertEqual(vc._privilege_prefix(), []) + + def test_create_nix_default_starts_with_sudo(self): + self.veracrypt.os_name = "Linux" + self.veracrypt.veracrypt_path = "/usr/bin/veracrypt" + + cmd = self.veracrypt._create_nix("/vol", 1024) + + self.assertEqual(cmd[0], "sudo") + self.assertEqual(cmd[1], "/usr/bin/veracrypt") + + def test_create_nix_no_sudo_starts_with_veracrypt_path(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._create_nix("/vol", 1024) + + self.assertNotIn("sudo", cmd) + self.assertEqual(cmd[0], "/usr/bin/veracrypt") + + def test_create_nix_non_interactive_yields_sudo_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + vc.os_name = "Linux" + + cmd = vc._create_nix("/vol", 1024) + + self.assertEqual(cmd[:2], ["sudo", "-n"]) + self.assertEqual(cmd[2], "/usr/bin/veracrypt") + + def test_mount_nix_no_sudo_omits_sudo(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._mount_nix("/vol") + + self.assertNotIn("sudo", cmd) + self.assertEqual(cmd[0], "/usr/bin/veracrypt") + + def test_dismount_nix_non_interactive_yields_sudo_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + vc.os_name = "Linux" + + cmd = vc._dismount_nix() + + self.assertEqual(cmd[:2], ["sudo", "-n"]) + self.assertIn("--unmount", cmd) + + def test_custom_nix_no_sudo_starts_with_veracrypt_path(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._custom_nix() + + self.assertEqual(cmd, ["/usr/bin/veracrypt"]) + def test_default_path_linux_uses_check_path(self): self.veracrypt.os_name = "Linux" with patch.object(self.veracrypt, "_check_path", return_value=True) as mock: