diff options
| -rw-r--r-- | .ruff.toml | 25 | ||||
| -rw-r--r-- | CHANGELOG.md | 11 | ||||
| -rw-r--r-- | README.md | 24 | ||||
| -rw-r--r-- | pyproject.toml | 14 | ||||
| -rw-r--r-- | src/etoolkit/__init__.py | 2 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 51 | ||||
| -rw-r--r-- | src/etoolkit/etoolkit.py | 141 | ||||
| -rw-r--r-- | tests/conftest.py | 38 | ||||
| -rw-r--r-- | tests/test_cli.py | 91 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance.py | 17 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance_static.py | 80 |
11 files changed, 287 insertions, 207 deletions
| @@ -2,36 +2,34 @@ cache-dir = "~/.cache/ruff" | |||
| 2 | indent-width = 4 | 2 | indent-width = 4 |
| 3 | line-length = 79 | 3 | line-length = 79 |
| 4 | target-version = "py310" | 4 | target-version = "py310" |
| 5 | namespace-packages = ["tests"] | ||
| 6 | |||
| 5 | 7 | ||
| 6 | [lint] | 8 | [lint] |
| 7 | select = ["ALL"] | 9 | select = ["ALL"] |
| 8 | ignore = ["COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D401", "D403", "D415", "ERA001", "FBT001", "FBT002", "PTH111", "RUF012", "RUF013", "S101", "TRY300", "BLE001", "UP020", "C901", "D200", "D402", "EM101", "EM102", "FBT003", "INP001", "PLR0912", "PLR0913", "PLR0915", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"] | 10 | ignore = ["BLE001", "COM812", "D203", "D205", "D212", "D400", "D401", "D403", "D415", "FBT001", "FBT002", "PTH111", "S101", "UP020", "D200", "D402", "EM101", "EM102", "FBT003", "PLR0913", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"] |
| 9 | # D105 - Missing docstring in magic method | 11 | |
| 10 | # D200 - One-line docstring should fit on one line | 12 | # BLE001 - Do not catch blind exception: `Exception` |
| 11 | # D203 - 1 blank line required before class docstring | 13 | # COM812 - Trailing comma missing |
| 12 | # D205 - 1 blank line required between summary line and description | 14 | # D205 - 1 blank line required between summary line and description |
| 15 | # D212 - Multi-line docstring summary should start at the first line | ||
| 16 | # D400 - First line should end with a period | ||
| 17 | # D401 - First line of docstring should be in imperative mood | ||
| 13 | # D403 - First word of the first line should be capitalized: `str` -> `Str` | 18 | # D403 - First word of the first line should be capitalized: `str` -> `Str` |
| 19 | # D415 - First line should end with a period, question mark, or exclamation point | ||
| 14 | # FBT001 - Boolean-typed positional argument in function definition | 20 | # FBT001 - Boolean-typed positional argument in function definition |
| 15 | # FBT002 - Boolean default positional argument in function definition | 21 | # FBT002 - Boolean default positional argument in function definition |
| 16 | # PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` | 22 | # PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` |
| 17 | # RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` | ||
| 18 | # RUF013 - PEP 484 prohibits implicit `Optional` | ||
| 19 | # S101 - Use of `assert` detected | 23 | # S101 - Use of `assert` detected |
| 20 | # TRY300 - Consider moving this statement to an `else` block | 24 | # TRY300 - Consider moving this statement to an `else` block |
| 21 | # TRY400 - Use `logging.exception` instead of `logging.error` | 25 | # TRY400 - Use `logging.exception` instead of `logging.error` |
| 22 | # UP020 - Use builtin `open` | 26 | # UP020 - Use builtin `open` |
| 23 | 27 | ||
| 24 | # Project specific | 28 | # Project specific |
| 25 | # C901 - `X` is too complex | ||
| 26 | # D200 - One-line docstring should fit on one line | ||
| 27 | # D402 - First line should not be the function's signature (bug in ruff 0.4.4) | ||
| 28 | # EM101 - Exception must not use a string literal, assign to variable first | 29 | # EM101 - Exception must not use a string literal, assign to variable first |
| 29 | # EM102 - Exception must not use an f-string literal, assign to variable first | 30 | # EM102 - Exception must not use an f-string literal, assign to variable first |
| 30 | # FBT003 - Boolean positional value in function call | 31 | # FBT003 - Boolean positional value in function call |
| 31 | # INP001 - File `tests/test_envtoolkit_instance_static.py` is part of an implicit namespace package. Add an `__init__.py`. | ||
| 32 | # PLR0912 - Too many branches | ||
| 33 | # PLR0913 - Too many arguments in function definition | 32 | # PLR0913 - Too many arguments in function definition |
| 34 | # PLR0915 - Too many statements | ||
| 35 | # PLR2004 - Magic value used in comparison, consider replacing `X` with a constant variable | 33 | # PLR2004 - Magic value used in comparison, consider replacing `X` with a constant variable |
| 36 | # PLW2901 - `for` loop variable `value` overwritten by assignment target | 34 | # PLW2901 - `for` loop variable `value` overwritten by assignment target |
| 37 | # S603 - `subprocess` call: check for execution of untrusted input | 35 | # S603 - `subprocess` call: check for execution of untrusted input |
| @@ -42,9 +40,6 @@ ignore = ["COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D40 | |||
| 42 | fixable = ["ALL"] | 40 | fixable = ["ALL"] |
| 43 | unfixable = [] | 41 | unfixable = [] |
| 44 | 42 | ||
| 45 | [lint.per-file-ignores] | ||
| 46 | "tests/*.py" = ["ANN"] # Do not require annotations for tests | ||
| 47 | |||
| 48 | 43 | ||
| 49 | [format] | 44 | [format] |
| 50 | # Like Black, use double quotes for strings. | 45 | # Like Black, use double quotes for strings. |
diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4373f..004f29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md | |||
| @@ -1,5 +1,16 @@ | |||
| 1 | # Changelog | 1 | # Changelog |
| 2 | 2 | ||
| 3 | ## [2.3.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.3.0) (2026-05-04) | ||
| 4 | |||
| 5 | [Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.2.0...2.3.0) | ||
| 6 | |||
| 7 | **Changes:** | ||
| 8 | |||
| 9 | - add support for the %e{key} format | ||
| 10 | |||
| 11 | - add support for type checkers (ty) | ||
| 12 | |||
| 13 | |||
| 3 | ## [2.2.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.2.0) (2026-04-16) | 14 | ## [2.2.0](https://codeberg.org/sgs/etoolkit/releases/tag/2.2.0) (2026-04-16) |
| 4 | 15 | ||
| 5 | [Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.1.0...2.2.0) | 16 | [Full Changelog](https://codeberg.org/sgs/etoolkit/compare/2.1.0...2.2.0) |
| @@ -56,10 +56,20 @@ for processes that were not spawned by that same *etoolkit* session. | |||
| 56 | 56 | ||
| 57 | ## Installation | 57 | ## Installation |
| 58 | 58 | ||
| 59 | ### pip (pypi) | 59 | ### Arch Linux |
| 60 | 60 | ||
| 61 | ```bash | 61 | ```bash |
| 62 | pip install etoolkit | 62 | # fetch sgs' developer key from the keyserver and sign it |
| 63 | pacman-key --recv-keys A6645797661E2F473DD3FF06BCE70555C3BB08F7 | ||
| 64 | pacman-key --lsign-key A6645797661E2F473DD3FF06BCE70555C3BB08F7 | ||
| 65 | |||
| 66 | # add sgs' repository in /etc/pacman.conf and require packages to be signed | ||
| 67 | [sgs] | ||
| 68 | Server = https://pkg.pichove.org/archlinux/$repo/os/$arch | ||
| 69 | SigLevel = PackageRequired | ||
| 70 | |||
| 71 | # install etoolkit | ||
| 72 | pacman -S python-etoolkit | ||
| 63 | ``` | 73 | ``` |
| 64 | 74 | ||
| 65 | 75 | ||
| @@ -72,6 +82,14 @@ for processes that were not spawned by that same *etoolkit* session. | |||
| 72 | emerge dev-python/etoolkit | 82 | emerge dev-python/etoolkit |
| 73 | ``` | 83 | ``` |
| 74 | 84 | ||
| 85 | |||
| 86 | ### pip (pypi) | ||
| 87 | |||
| 88 | ```bash | ||
| 89 | pip install etoolkit | ||
| 90 | ``` | ||
| 91 | |||
| 92 | |||
| 75 | ## Encryption & decryption scheme | 93 | ## Encryption & decryption scheme |
| 76 | 94 | ||
| 77 | The etoolkit encryption format is currently at version 2. | 95 | The etoolkit encryption format is currently at version 2. |
| @@ -262,6 +280,8 @@ When all values are fetched from a given instance (and its parents) and then | |||
| 262 | decrypted, they are further processed by replacing macros with their | 280 | decrypted, they are further processed by replacing macros with their |
| 263 | corresponding values. Currently the following macros are supported: | 281 | corresponding values. Currently the following macros are supported: |
| 264 | 282 | ||
| 283 | - **%e{MYVAR}** - the current value of the env. var. *MYVAR* | ||
| 284 | |||
| 265 | - **%e** - the current value of the env. var. corresponding to the same key | 285 | - **%e** - the current value of the env. var. corresponding to the same key |
| 266 | 286 | ||
| 267 | - **%h** - the home directory of the user running *etoolkit* (~/) | 287 | - **%h** - the home directory of the user running *etoolkit* (~/) |
diff --git a/pyproject.toml b/pyproject.toml index e44adbb..4f7a722 100644 --- a/pyproject.toml +++ b/pyproject.toml | |||
| @@ -28,6 +28,14 @@ dependencies = [ | |||
| 28 | ] | 28 | ] |
| 29 | 29 | ||
| 30 | 30 | ||
| 31 | [dependency-groups] | ||
| 32 | dev = [ | ||
| 33 | "pytest>=6", | ||
| 34 | "ruff>=0.15.0", | ||
| 35 | "ty>=0.0.31", | ||
| 36 | ] | ||
| 37 | |||
| 38 | |||
| 31 | [project.scripts] | 39 | [project.scripts] |
| 32 | etoolkit = "etoolkit.__main__:main" | 40 | etoolkit = "etoolkit.__main__:main" |
| 33 | 41 | ||
| @@ -42,6 +50,7 @@ Changelog = "https://codeberg.org/sgs/etoolkit/src/branch/master/CHANGELOG.md" | |||
| 42 | [tool.pytest.ini_options] | 50 | [tool.pytest.ini_options] |
| 43 | minversion = "6.0" | 51 | minversion = "6.0" |
| 44 | addopts = "-s" | 52 | addopts = "-s" |
| 53 | cache_dir = "~/.cache/pytest_cache" | ||
| 45 | testpaths = [ | 54 | testpaths = [ |
| 46 | "tests" | 55 | "tests" |
| 47 | ] | 56 | ] |
| @@ -54,6 +63,11 @@ pythonpath = [ | |||
| 54 | version = {attr = "etoolkit.__version__"} | 63 | version = {attr = "etoolkit.__version__"} |
| 55 | 64 | ||
| 56 | 65 | ||
| 66 | [tool.ty.environment] | ||
| 67 | python-version = "3.10" | ||
| 68 | root = ["./src"] | ||
| 69 | |||
| 70 | |||
| 57 | [build-system] | 71 | [build-system] |
| 58 | requires = [ | 72 | requires = [ |
| 59 | "setuptools >= 77.0.3", | 73 | "setuptools >= 77.0.3", |
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index fc32b61..416be15 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py | |||
| @@ -18,7 +18,7 @@ | |||
| 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError |
| 19 | 19 | ||
| 20 | __author__ = 'Simeon Simeonov' | 20 | __author__ = 'Simeon Simeonov' |
| 21 | __version__ = '2.2.0' | 21 | __version__ = '2.3.0' |
| 22 | __license__ = 'GPL3' | 22 | __license__ = 'GPL3' |
| 23 | 23 | ||
| 24 | 24 | ||
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index cef3d42..33e5741 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py | |||
| @@ -61,10 +61,11 @@ class EtoolkitCLIHandler: | |||
| 61 | self._args = args | 61 | self._args = args |
| 62 | self._config_dict = config_dict | 62 | self._config_dict = config_dict |
| 63 | 63 | ||
| 64 | self._password_hash = None | 64 | self._password_hash: str = '' |
| 65 | |||
| 65 | if 'general' in config_dict: | 66 | if 'general' in config_dict: |
| 66 | self._password_hash = config_dict['general'].get( | 67 | self._password_hash = config_dict['general'].get( |
| 67 | 'MASTER_PASSWORD_HASH' | 68 | 'MASTER_PASSWORD_HASH', '' |
| 68 | ) | 69 | ) |
| 69 | 70 | ||
| 70 | self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 71 | self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| @@ -168,9 +169,28 @@ class EtoolkitCLIHandler: | |||
| 168 | ) | 169 | ) |
| 169 | print(f'Master password hash: {phash}') | 170 | print(f'Master password hash: {phash}') |
| 170 | 171 | ||
| 172 | def handle_args(self) -> None: | ||
| 173 | """Runs the handler""" | ||
| 174 | if self._args.decrypt_value: | ||
| 175 | self.decrypt_value() | ||
| 176 | return | ||
| 177 | if self._args.encrypt_value: | ||
| 178 | self.encrypt_value() | ||
| 179 | return | ||
| 180 | if self._args.password_hash: | ||
| 181 | self.generate_master_password_hash() | ||
| 182 | return | ||
| 183 | if self._args.list: | ||
| 184 | self.list() | ||
| 185 | return | ||
| 186 | if self._args.reencrypt: | ||
| 187 | self.reencrypt() | ||
| 188 | return | ||
| 189 | |||
| 190 | self.load_instance() | ||
| 191 | |||
| 171 | def list(self) -> None: | 192 | def list(self) -> None: |
| 172 | """Lists all instances defined in the config file""" | 193 | """Lists all instances defined in the config file""" |
| 173 | |||
| 174 | for instance_name in sorted( | 194 | for instance_name in sorted( |
| 175 | filter( | 195 | filter( |
| 176 | lambda s: not s.startswith('_'), | 196 | lambda s: not s.startswith('_'), |
| @@ -181,7 +201,6 @@ class EtoolkitCLIHandler: | |||
| 181 | 201 | ||
| 182 | def load_instance(self) -> None: | 202 | def load_instance(self) -> None: |
| 183 | """Loads a single specified instance from the config file""" | 203 | """Loads a single specified instance from the config file""" |
| 184 | |||
| 185 | inst = etoolkit.EtoolkitInstance( | 204 | inst = etoolkit.EtoolkitInstance( |
| 186 | self._args.instance, self._config_dict | 205 | self._args.instance, self._config_dict |
| 187 | ) | 206 | ) |
| @@ -277,9 +296,8 @@ class EtoolkitCLIHandler: | |||
| 277 | ) | 296 | ) |
| 278 | 297 | ||
| 279 | 298 | ||
| 280 | def main(inargs: list = None) -> None: | 299 | def main(inargs: list | None = None) -> None: |
| 281 | """main entry point""" | 300 | """main entry point""" |
| 282 | |||
| 283 | parser = argparse.ArgumentParser( | 301 | parser = argparse.ArgumentParser( |
| 284 | prog=__package__, | 302 | prog=__package__, |
| 285 | epilog=( | 303 | epilog=( |
| @@ -435,26 +453,11 @@ def main(inargs: list = None) -> None: | |||
| 435 | raise SystemExit(errno.EIO) from exp | 453 | raise SystemExit(errno.EIO) from exp |
| 436 | try: | 454 | try: |
| 437 | etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) | 455 | etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) |
| 438 | if args.decrypt_value: | 456 | etoolkit_cli_handler.handle_args() |
| 439 | etoolkit_cli_handler.decrypt_value() | 457 | sys.exit(0) |
| 440 | sys.exit(0) | ||
| 441 | if args.encrypt_value: | ||
| 442 | etoolkit_cli_handler.encrypt_value() | ||
| 443 | sys.exit(0) | ||
| 444 | if args.password_hash: | ||
| 445 | etoolkit_cli_handler.generate_master_password_hash() | ||
| 446 | sys.exit(0) | ||
| 447 | if args.list: | ||
| 448 | etoolkit_cli_handler.list() | ||
| 449 | sys.exit(0) | ||
| 450 | if args.reencrypt: | ||
| 451 | etoolkit_cli_handler.reencrypt() | ||
| 452 | sys.exit(0) | ||
| 453 | |||
| 454 | etoolkit_cli_handler.load_instance() | ||
| 455 | except KeyboardInterrupt: | 458 | except KeyboardInterrupt: |
| 456 | logger.debug('KeyboardInterrupt') | 459 | logger.debug('KeyboardInterrupt') |
| 457 | print(os.linesep) | 460 | print('\n') |
| 458 | sys.exit(0) | 461 | sys.exit(0) |
| 459 | except etoolkit.EtoolkitInstanceError as err: | 462 | except etoolkit.EtoolkitInstanceError as err: |
| 460 | logger.error('EtoolkitInstanceError: %s', err) | 463 | logger.error('EtoolkitInstanceError: %s', err) |
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index f5ce2a6..9369571 100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py | |||
| @@ -19,7 +19,7 @@ import base64 | |||
| 19 | import getpass | 19 | import getpass |
| 20 | import hashlib | 20 | import hashlib |
| 21 | import os | 21 | import os |
| 22 | from collections.abc import Callable | 22 | from typing import Protocol |
| 23 | 23 | ||
| 24 | from cryptography.exceptions import InvalidTag | 24 | from cryptography.exceptions import InvalidTag |
| 25 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | 25 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM |
| @@ -31,6 +31,24 @@ class EtoolkitInstanceError(Exception): | |||
| 31 | """EtoolkitInstanceError - Generic exceptions related to instances""" | 31 | """EtoolkitInstanceError - Generic exceptions related to instances""" |
| 32 | 32 | ||
| 33 | 33 | ||
| 34 | class PromptFuncProtocol(Protocol): | ||
| 35 | """Specialized callable for prompt functions""" | ||
| 36 | |||
| 37 | def __call__(self, password_hash: str = '', confirm: bool = True) -> str: | ||
| 38 | """ | ||
| 39 | Prompts for master password and then for confirmation if `confirm` True | ||
| 40 | |||
| 41 | :param password_hash: Hash to compare with instead of confirm (def. '') | ||
| 42 | :type password_hash: str | ||
| 43 | |||
| 44 | :param confirm: Confirm the password (and see if there is a match) | ||
| 45 | :type confirm: bool | ||
| 46 | |||
| 47 | :return: Password provided by the user | ||
| 48 | :rtype: str | ||
| 49 | """ | ||
| 50 | |||
| 51 | |||
| 34 | class EtoolkitInstance: | 52 | class EtoolkitInstance: |
| 35 | """A basic class representing a single instance""" | 53 | """A basic class representing a single instance""" |
| 36 | 54 | ||
| @@ -47,9 +65,12 @@ class EtoolkitInstance: | |||
| 47 | self._env = None | 65 | self._env = None |
| 48 | self._raw_env_variables = {} | 66 | self._raw_env_variables = {} |
| 49 | self._sensitive_env_variables = [] | 67 | self._sensitive_env_variables = [] |
| 50 | self._master_password = None | 68 | self._master_password: str | None = None |
| 51 | self._master_password_hash = None | 69 | self._master_password_hash: str = '' |
| 52 | self._prompt_func = None # function to use when prompting for input | 70 | |
| 71 | # function to use when prompting for input | ||
| 72 | self._prompt_func: PromptFuncProtocol | None = None | ||
| 73 | |||
| 53 | try: | 74 | try: |
| 54 | self._instance_data = data['instances'][name] | 75 | self._instance_data = data['instances'][name] |
| 55 | except KeyError as err: | 76 | except KeyError as err: |
| @@ -106,12 +127,12 @@ class EtoolkitInstance: | |||
| 106 | return self._name | 127 | return self._name |
| 107 | 128 | ||
| 108 | @property | 129 | @property |
| 109 | def prompt_func(self) -> Callable[[str, bool], str]: | 130 | def prompt_func(self) -> PromptFuncProtocol | None: |
| 110 | """prompt_func-property""" | 131 | """prompt_func-property""" |
| 111 | return self._prompt_func | 132 | return self._prompt_func |
| 112 | 133 | ||
| 113 | @prompt_func.setter | 134 | @prompt_func.setter |
| 114 | def prompt_func(self, value: Callable[[str, bool], str]) -> None: | 135 | def prompt_func(self, value: PromptFuncProtocol) -> None: |
| 115 | """prompt_func-property setter""" | 136 | """prompt_func-property setter""" |
| 116 | self._prompt_func = value | 137 | self._prompt_func = value |
| 117 | if self._parent is not None and self._parent.prompt_func is None: | 138 | if self._parent is not None and self._parent.prompt_func is None: |
| @@ -130,12 +151,12 @@ class EtoolkitInstance: | |||
| 130 | 151 | ||
| 131 | @staticmethod | 152 | @staticmethod |
| 132 | def confirm_password_prompt( | 153 | def confirm_password_prompt( |
| 133 | password_hash: str = None, confirm: bool = True | 154 | password_hash: str = '', confirm: bool = True |
| 134 | ) -> str: | 155 | ) -> str: |
| 135 | """ | 156 | """ |
| 136 | Prompts for master password and then for confirmation if `confirm` True | 157 | Prompts for master password and then for confirmation if `confirm` True |
| 137 | 158 | ||
| 138 | :param password_hash: Hash to compare with instead of confirm | 159 | :param password_hash: Hash to compare with instead of confirm (def. '') |
| 139 | :type password_hash: str | 160 | :type password_hash: str |
| 140 | 161 | ||
| 141 | :param confirm: Confirm the password (and see if there is a match) | 162 | :param confirm: Confirm the password (and see if there is a match) |
| @@ -158,8 +179,8 @@ class EtoolkitInstance: | |||
| 158 | print('The passwords are either empty or do not match') | 179 | print('The passwords are either empty or do not match') |
| 159 | continue | 180 | continue |
| 160 | return pass1.strip() | 181 | return pass1.strip() |
| 161 | except Exception as e: | 182 | except Exception as exp: |
| 162 | raise EtoolkitInstanceError('Prompt error') from e | 183 | raise EtoolkitInstanceError('Prompt error') from exp |
| 163 | 184 | ||
| 164 | @staticmethod | 185 | @staticmethod |
| 165 | def decrypt(password: str, edata: str) -> str: | 186 | def decrypt(password: str, edata: str) -> str: |
| @@ -206,14 +227,14 @@ class EtoolkitInstance: | |||
| 206 | data = data[2 : -int(data[:2].decode())] | 227 | data = data[2 : -int(data[:2].decode())] |
| 207 | 228 | ||
| 208 | return data.decode() | 229 | return data.decode() |
| 209 | except InvalidTag as e: | 230 | except InvalidTag as err: |
| 210 | raise EtoolkitInstanceError( | 231 | raise EtoolkitInstanceError( |
| 211 | f'Invalid tag when decrypting: {edata}' | 232 | f'Invalid tag when decrypting: {edata}' |
| 212 | ) from e | 233 | ) from err |
| 213 | except Exception as e: | 234 | except Exception as exp: |
| 214 | raise EtoolkitInstanceError( | 235 | raise EtoolkitInstanceError( |
| 215 | f'Error when decrypting: {edata}' | 236 | f'Error when decrypting: {edata}' |
| 216 | ) from e | 237 | ) from exp |
| 217 | 238 | ||
| 218 | @staticmethod | 239 | @staticmethod |
| 219 | def encrypt(password: str, data: str) -> str: | 240 | def encrypt(password: str, data: str) -> str: |
| @@ -269,6 +290,28 @@ class EtoolkitInstance: | |||
| 269 | ) | 290 | ) |
| 270 | 291 | ||
| 271 | @staticmethod | 292 | @staticmethod |
| 293 | def get_global_macros() -> dict[str, str]: | ||
| 294 | """ | ||
| 295 | Returns a dict for global macro mapping | ||
| 296 | |||
| 297 | Globals are the same for all instances | ||
| 298 | |||
| 299 | :return: macro: replacement value dict | ||
| 300 | :rtype: dict | ||
| 301 | """ | ||
| 302 | macros = {'%h': os.path.expanduser('~'), '%u': getpass.getuser()} | ||
| 303 | |||
| 304 | # unpack defined environment variables | ||
| 305 | for key, value in os.environ.items(): | ||
| 306 | if not isinstance(value, str): | ||
| 307 | # should not happen | ||
| 308 | continue | ||
| 309 | |||
| 310 | macros[f'%e{{{key}}}'] = value | ||
| 311 | |||
| 312 | return macros | ||
| 313 | |||
| 314 | @staticmethod | ||
| 272 | def get_new_password_hash(password: str) -> str: | 315 | def get_new_password_hash(password: str) -> str: |
| 273 | """ | 316 | """ |
| 274 | Returns a complete password hash based on `password` | 317 | Returns a complete password hash based on `password` |
| @@ -295,23 +338,23 @@ class EtoolkitInstance: | |||
| 295 | ) | 338 | ) |
| 296 | 339 | ||
| 297 | @staticmethod | 340 | @staticmethod |
| 298 | def parse_value(value: object, macros: dict) -> object: | 341 | def parse_value(value: str, macros: dict) -> str: |
| 299 | """ | 342 | """ |
| 300 | Returns the value with all macros replaced by their values | 343 | Returns the value with all macros replaced by their values |
| 301 | 344 | ||
| 302 | If `value` is not of type 'str' simply return `value` | ||
| 303 | |||
| 304 | :param value: A simple value | 345 | :param value: A simple value |
| 305 | :type value: object | 346 | :type value: str |
| 306 | 347 | ||
| 307 | :param macros: Macros mapping | 348 | :param macros: Macros mapping |
| 308 | :type macros: dict | 349 | :type macros: dict |
| 309 | 350 | ||
| 310 | :return: New value with all macros replaced by their values | 351 | :return: New value with all macros replaced by their values |
| 311 | :rtype: object | 352 | :rtype: str |
| 312 | """ | 353 | """ |
| 313 | if not isinstance(value, str): | 354 | if not isinstance(value, str): |
| 314 | return value | 355 | raise EtoolkitInstanceError( |
| 356 | "Environment variable value not of the type 'str' detected" | ||
| 357 | ) | ||
| 315 | for key, val in macros.items(): | 358 | for key, val in macros.items(): |
| 316 | value = value.replace(key, val) | 359 | value = value.replace(key, val) |
| 317 | return value | 360 | return value |
| @@ -332,7 +375,7 @@ class EtoolkitInstance: | |||
| 332 | :return: True if the password matches or password_hash is None, | 375 | :return: True if the password matches or password_hash is None, |
| 333 | :rtype: bool | 376 | :rtype: bool |
| 334 | """ | 377 | """ |
| 335 | if password_hash is None: | 378 | if not password_hash: |
| 336 | return True | 379 | return True |
| 337 | # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 | 380 | # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 |
| 338 | try: | 381 | try: |
| @@ -401,11 +444,9 @@ class EtoolkitInstance: | |||
| 401 | if self._env is not None: | 444 | if self._env is not None: |
| 402 | return self._env | 445 | return self._env |
| 403 | 446 | ||
| 404 | macros = { | 447 | macros = self.get_global_macros() |
| 405 | '%h': os.path.expanduser('~'), | 448 | macros['%i'] = self.name |
| 406 | '%i': self.name, | 449 | |
| 407 | '%u': getpass.getuser(), | ||
| 408 | } | ||
| 409 | new_env = {} | 450 | new_env = {} |
| 410 | for key, value in sorted( | 451 | for key, value in sorted( |
| 411 | self._raw_env_variables.items(), key=lambda x: x[0] | 452 | self._raw_env_variables.items(), key=lambda x: x[0] |
| @@ -413,14 +454,12 @@ class EtoolkitInstance: | |||
| 413 | if not value: | 454 | if not value: |
| 414 | # perhaps unset instead of skipping? | 455 | # perhaps unset instead of skipping? |
| 415 | continue | 456 | continue |
| 416 | if isinstance(value, str) and '%e' in value: | 457 | macros['%e'] = os.environ.get(key, '') |
| 417 | macros['%e'] = os.environ.get(key, '') | 458 | macros['%p'] = ( |
| 418 | if isinstance(value, str) and '%p' in value: | 459 | self._parent.get_environ().get(key, '') |
| 419 | macros['%p'] = ( | 460 | if self._parent is not None |
| 420 | self._parent.get_environ().get(key, '') | 461 | else '' |
| 421 | if self._parent is not None | 462 | ) |
| 422 | else '' | ||
| 423 | ) | ||
| 424 | if isinstance(value, str) and value.startswith('enc-val$'): | 463 | if isinstance(value, str) and value.startswith('enc-val$'): |
| 425 | value = self._decrypt_value(value) | 464 | value = self._decrypt_value(value) |
| 426 | if key not in self._sensitive_env_variables: | 465 | if key not in self._sensitive_env_variables: |
| @@ -469,7 +508,7 @@ class EtoolkitInstance: | |||
| 469 | return self._parent.get_full_name(delimiter) + delimiter + self.name | 508 | return self._parent.get_full_name(delimiter) + delimiter + self.name |
| 470 | 509 | ||
| 471 | def get_reencrypted_instance_data( | 510 | def get_reencrypted_instance_data( |
| 472 | self, new_password: str, password: str = None | 511 | self, new_password: str, password: str | None = None |
| 473 | ) -> dict: | 512 | ) -> dict: |
| 474 | """ | 513 | """ |
| 475 | Returns new instance data (dict) containing new encrypted values | 514 | Returns new instance data (dict) containing new encrypted values |
| @@ -493,16 +532,18 @@ class EtoolkitInstance: | |||
| 493 | if password is None: | 532 | if password is None: |
| 494 | password = self._master_password | 533 | password = self._master_password |
| 495 | 534 | ||
| 496 | if password is None and self._prompt_func is None: | 535 | if password is None: |
| 497 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 536 | if self._prompt_func is None: |
| 498 | if password is None: | 537 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| 499 | raise EtoolkitInstanceError( | 538 | else: |
| 500 | 'Neither password or prompt function set' | 539 | password = self._prompt_func( |
| 540 | self._master_password_hash, confirm=False | ||
| 501 | ) | 541 | ) |
| 502 | 542 | ||
| 503 | if password is None: | 543 | if password is None: |
| 504 | password = self._prompt_func( | 544 | # the password is still not set |
| 505 | self._master_password_hash, confirm=False | 545 | raise EtoolkitInstanceError( |
| 546 | 'Neither password or prompt function set' | ||
| 506 | ) | 547 | ) |
| 507 | 548 | ||
| 508 | new_data = dict(self._instance_data) | 549 | new_data = dict(self._instance_data) |
| @@ -529,7 +570,10 @@ class EtoolkitInstance: | |||
| 529 | :return: Decrypted value | 570 | :return: Decrypted value |
| 530 | :rtype: str | 571 | :rtype: str |
| 531 | """ | 572 | """ |
| 532 | if self._master_password is None: | 573 | if self._master_password is not None: |
| 574 | master_password = self._master_password | ||
| 575 | else: | ||
| 576 | # master password not set for this instance | ||
| 533 | if self._prompt_func is None: | 577 | if self._prompt_func is None: |
| 534 | if ( | 578 | if ( |
| 535 | mp_from_env := os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 579 | mp_from_env := os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| @@ -537,10 +581,13 @@ class EtoolkitInstance: | |||
| 537 | raise EtoolkitInstanceError( | 581 | raise EtoolkitInstanceError( |
| 538 | 'Neither password or prompt function set' | 582 | 'Neither password or prompt function set' |
| 539 | ) | 583 | ) |
| 540 | self.master_password = mp_from_env | 584 | master_password = mp_from_env |
| 541 | else: | 585 | else: |
| 542 | # use master_password setter in order to propagate to parent | 586 | master_password = self._prompt_func( |
| 543 | self.master_password = self._prompt_func( | ||
| 544 | self._master_password_hash, confirm=False | 587 | self._master_password_hash, confirm=False |
| 545 | ) | 588 | ) |
| 546 | return self.decrypt(self._master_password, evalue) | 589 | |
| 590 | # use master_password setter in order to propagate to parent | ||
| 591 | self.master_password = master_password | ||
| 592 | |||
| 593 | return self.decrypt(master_password, evalue) | ||
diff --git a/tests/conftest.py b/tests/conftest.py index 6cc8b9f..2e8eaf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py | |||
| @@ -16,14 +16,14 @@ | |||
| 16 | """Common fixtures""" | 16 | """Common fixtures""" |
| 17 | 17 | ||
| 18 | import json | 18 | import json |
| 19 | import pathlib | ||
| 19 | 20 | ||
| 20 | import pytest | 21 | import pytest |
| 21 | 22 | ||
| 22 | 23 | ||
| 23 | @pytest.fixture | 24 | @pytest.fixture |
| 24 | def config_data(): | 25 | def config_data() -> dict: |
| 25 | """config_data for testing""" | 26 | """config_data for testing""" |
| 26 | |||
| 27 | return { | 27 | return { |
| 28 | 'general': { | 28 | 'general': { |
| 29 | 'MASTER_PASSWORD_HASH': ( | 29 | 'MASTER_PASSWORD_HASH': ( |
| @@ -60,18 +60,16 @@ def config_data(): | |||
| 60 | 60 | ||
| 61 | 61 | ||
| 62 | @pytest.fixture | 62 | @pytest.fixture |
| 63 | def config_file(tmp_path, config_data): | 63 | def config_file(tmp_path: pathlib.Path, config_data: dict) -> str: |
| 64 | """Temporary config file for testing that includes config_data""" | 64 | """Temporary config file for testing that includes config_data""" |
| 65 | |||
| 66 | cf = tmp_path / 'etoolkit.json' | 65 | cf = tmp_path / 'etoolkit.json' |
| 67 | cf.write_text(json.dumps(config_data)) | 66 | cf.write_text(json.dumps(config_data)) |
| 68 | return str(cf) | 67 | return str(cf) |
| 69 | 68 | ||
| 70 | 69 | ||
| 71 | @pytest.fixture | 70 | @pytest.fixture |
| 72 | def long_encrypted_value(): | 71 | def long_encrypted_value() -> str: |
| 73 | """enc. value corresponding to 'Nobody expects the Spanish inquisition'""" | 72 | """enc. value corresponding to 'Nobody expects the Spanish inquisition'""" |
| 74 | |||
| 75 | return ( | 73 | return ( |
| 76 | 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' | 74 | 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' |
| 77 | 'UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hF' | 75 | 'UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hF' |
| @@ -80,27 +78,26 @@ def long_encrypted_value(): | |||
| 80 | 78 | ||
| 81 | 79 | ||
| 82 | @pytest.fixture | 80 | @pytest.fixture |
| 83 | def long_value(): | 81 | def long_value() -> str: |
| 84 | """standard value (> 32 bytes)""" | 82 | """standard value (> 32 bytes)""" |
| 85 | return 'Nobody expects the Spanish inquisition' | 83 | return 'Nobody expects the Spanish inquisition' |
| 86 | 84 | ||
| 87 | 85 | ||
| 88 | @pytest.fixture | 86 | @pytest.fixture |
| 89 | def master_password(): | 87 | def master_password() -> str: |
| 90 | """Master passord""" | 88 | """Master passord""" |
| 91 | return 'The very secret passwd' | 89 | return 'The very secret passwd' |
| 92 | 90 | ||
| 93 | 91 | ||
| 94 | @pytest.fixture | 92 | @pytest.fixture |
| 95 | def new_master_password(): | 93 | def new_master_password() -> str: |
| 96 | """Master passord""" | 94 | """Master passord""" |
| 97 | return 'New very secret passwd' | 95 | return 'New very secret passwd' |
| 98 | 96 | ||
| 99 | 97 | ||
| 100 | @pytest.fixture | 98 | @pytest.fixture |
| 101 | def non_random_bytes_32(): | 99 | def non_random_bytes_32() -> bytes: |
| 102 | """always use the same bytes instead of os.urandom(32)""" | 100 | """always use the same bytes instead of os.urandom(32)""" |
| 103 | |||
| 104 | return ( | 101 | return ( |
| 105 | b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N' | 102 | b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N' |
| 106 | b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J' | 103 | b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J' |
| @@ -108,9 +105,8 @@ def non_random_bytes_32(): | |||
| 108 | 105 | ||
| 109 | 106 | ||
| 110 | @pytest.fixture | 107 | @pytest.fixture |
| 111 | def non_random_bytes_57(): | 108 | def non_random_bytes_57() -> bytes: |
| 112 | """always use the same bytes instead of os.urandom(57)""" | 109 | """always use the same bytes instead of os.urandom(57)""" |
| 113 | |||
| 114 | return ( | 110 | return ( |
| 115 | b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa' | 111 | b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa' |
| 116 | b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e' | 112 | b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e' |
| @@ -119,16 +115,14 @@ def non_random_bytes_57(): | |||
| 119 | 115 | ||
| 120 | 116 | ||
| 121 | @pytest.fixture | 117 | @pytest.fixture |
| 122 | def nonexistent_config_file(tmp_path): | 118 | def nonexistent_config_file(tmp_path: pathlib.Path) -> str: |
| 123 | """temporary config file for testing that includes config_data""" | 119 | """temporary config file for testing that includes config_data""" |
| 124 | |||
| 125 | return str(tmp_path / 'etoolkitt.json') | 120 | return str(tmp_path / 'etoolkitt.json') |
| 126 | 121 | ||
| 127 | 122 | ||
| 128 | @pytest.fixture | 123 | @pytest.fixture |
| 129 | def password_hash(): | 124 | def password_hash() -> str: |
| 130 | """password hash for testing, corresponding to 'The very secret passwd'""" | 125 | """password hash for testing, corresponding to 'The very secret passwd'""" |
| 131 | |||
| 132 | return ( | 126 | return ( |
| 133 | 'pbkdf2_sha256$500000$UY3o78KUM1Btzxk3k3JCsijnwtJ2lx+hH9NewpVKxo8=$' | 127 | 'pbkdf2_sha256$500000$UY3o78KUM1Btzxk3k3JCsijnwtJ2lx+hH9NewpVKxo8=$' |
| 134 | 'tHwDm8OVKanC4DoYTigTCb0R3lQIa/CbBYj0B3TZtHg=' | 128 | 'tHwDm8OVKanC4DoYTigTCb0R3lQIa/CbBYj0B3TZtHg=' |
| @@ -136,9 +130,8 @@ def password_hash(): | |||
| 136 | 130 | ||
| 137 | 131 | ||
| 138 | @pytest.fixture | 132 | @pytest.fixture |
| 139 | def short_encrypted_value(): | 133 | def short_encrypted_value() -> str: |
| 140 | """enc. value corresponding to 'secret1'""" | 134 | """enc. value corresponding to 'secret1'""" |
| 141 | |||
| 142 | return ( | 135 | return ( |
| 143 | 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' | 136 | 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' |
| 144 | '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=' | 137 | '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=' |
| @@ -146,9 +139,8 @@ def short_encrypted_value(): | |||
| 146 | 139 | ||
| 147 | 140 | ||
| 148 | @pytest.fixture | 141 | @pytest.fixture |
| 149 | def short_encrypted_value_v1(): | 142 | def short_encrypted_value_v1() -> str: |
| 150 | """enc. value (enc-val 1) corresponding to 'secret1'""" | 143 | """enc. value (enc-val 1) corresponding to 'secret1'""" |
| 151 | |||
| 152 | return ( | 144 | return ( |
| 153 | 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx' | 145 | 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx' |
| 154 | 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' | 146 | 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' |
| @@ -156,12 +148,12 @@ def short_encrypted_value_v1(): | |||
| 156 | 148 | ||
| 157 | 149 | ||
| 158 | @pytest.fixture | 150 | @pytest.fixture |
| 159 | def short_value(): | 151 | def short_value() -> str: |
| 160 | """standard value (< 32 bytes)""" | 152 | """standard value (< 32 bytes)""" |
| 161 | return 'secret1' | 153 | return 'secret1' |
| 162 | 154 | ||
| 163 | 155 | ||
| 164 | @pytest.fixture | 156 | @pytest.fixture |
| 165 | def wrong_master_password(): | 157 | def wrong_master_password() -> str: |
| 166 | """Wrong master passord""" | 158 | """Wrong master passord""" |
| 167 | return 'the very secret passwd' | 159 | return 'the very secret passwd' |
diff --git a/tests/test_cli.py b/tests/test_cli.py index b7913da..6e28378 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py | |||
| @@ -26,9 +26,13 @@ from etoolkit.__main__ import main | |||
| 26 | 26 | ||
| 27 | 27 | ||
| 28 | @unittest.mock.patch('builtins.input') | 28 | @unittest.mock.patch('builtins.input') |
| 29 | def test_decrypt_v1(binput, capsys, config_file, master_password): | 29 | def test_decrypt_v1( |
| 30 | binput: unittest.mock.MagicMock, | ||
| 31 | capsys: pytest.CaptureFixture, | ||
| 32 | config_file: str, | ||
| 33 | master_password: str, | ||
| 34 | ) -> None: | ||
| 30 | """Tests v1 decryption via the CLI interface""" | 35 | """Tests v1 decryption via the CLI interface""" |
| 31 | |||
| 32 | binput.return_value = ( | 36 | binput.return_value = ( |
| 33 | 'enc-val$1$rye0sMGEnd35gOWyISE1FQa6dzS+8/jf6aopMO5tPr4=$' | 37 | 'enc-val$1$rye0sMGEnd35gOWyISE1FQa6dzS+8/jf6aopMO5tPr4=$' |
| 34 | 'RjnRY0bUJWFOiejTlM3OhKNimQ==' | 38 | 'RjnRY0bUJWFOiejTlM3OhKNimQ==' |
| @@ -44,15 +48,14 @@ def test_decrypt_v1(binput, capsys, config_file, master_password): | |||
| 44 | 48 | ||
| 45 | @unittest.mock.patch('builtins.input') | 49 | @unittest.mock.patch('builtins.input') |
| 46 | def test_decrypt_v2( | 50 | def test_decrypt_v2( |
| 47 | binput, | 51 | binput: unittest.mock.MagicMock, |
| 48 | capsys, | 52 | capsys: pytest.CaptureFixture, |
| 49 | config_file, | 53 | config_file: str, |
| 50 | master_password, | 54 | master_password: str, |
| 51 | short_encrypted_value, | 55 | short_encrypted_value: str, |
| 52 | short_value, | 56 | short_value: str, |
| 53 | ): | 57 | ) -> None: |
| 54 | """Tests v2 decryption via the CLI interface""" | 58 | """Tests v2 decryption via the CLI interface""" |
| 55 | |||
| 56 | binput.return_value = short_encrypted_value | 59 | binput.return_value = short_encrypted_value |
| 57 | with unittest.mock.patch.dict( | 60 | with unittest.mock.patch.dict( |
| 58 | os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} | 61 | os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} |
| @@ -68,17 +71,16 @@ def test_decrypt_v2( | |||
| 68 | @unittest.mock.patch('os.urandom') | 71 | @unittest.mock.patch('os.urandom') |
| 69 | @unittest.mock.patch('builtins.input') | 72 | @unittest.mock.patch('builtins.input') |
| 70 | def test_encrypt_with_echo( | 73 | def test_encrypt_with_echo( |
| 71 | binput, | 74 | binput: unittest.mock.MagicMock, |
| 72 | urandom, | 75 | urandom: unittest.mock.MagicMock, |
| 73 | capsys, | 76 | capsys: pytest.CaptureFixture, |
| 74 | non_random_bytes_57, | 77 | non_random_bytes_57: bytes, |
| 75 | config_file, | 78 | config_file: str, |
| 76 | master_password, | 79 | master_password: str, |
| 77 | short_encrypted_value, | 80 | short_encrypted_value: str, |
| 78 | short_value, | 81 | short_value: str, |
| 79 | ): | 82 | ) -> None: |
| 80 | """Tests encryption via the CLI interface""" | 83 | """Tests encryption via the CLI interface""" |
| 81 | |||
| 82 | binput.return_value = short_value | 84 | binput.return_value = short_value |
| 83 | urandom.return_value = non_random_bytes_57 | 85 | urandom.return_value = non_random_bytes_57 |
| 84 | with unittest.mock.patch.dict( | 86 | with unittest.mock.patch.dict( |
| @@ -95,17 +97,16 @@ def test_encrypt_with_echo( | |||
| 95 | @unittest.mock.patch('os.urandom') | 97 | @unittest.mock.patch('os.urandom') |
| 96 | @unittest.mock.patch('getpass.getpass') | 98 | @unittest.mock.patch('getpass.getpass') |
| 97 | def test_encrypt_without_echo( | 99 | def test_encrypt_without_echo( |
| 98 | getpass, | 100 | getpass: unittest.mock.MagicMock, |
| 99 | urandom, | 101 | urandom: unittest.mock.MagicMock, |
| 100 | capsys, | 102 | capsys: pytest.CaptureFixture, |
| 101 | non_random_bytes_57, | 103 | non_random_bytes_57: bytes, |
| 102 | config_file, | 104 | config_file: str, |
| 103 | master_password, | 105 | master_password: str, |
| 104 | short_encrypted_value, | 106 | short_encrypted_value: str, |
| 105 | short_value, | 107 | short_value: str, |
| 106 | ): | 108 | ) -> None: |
| 107 | """Tests encryption via the CLI interface""" | 109 | """Tests encryption via the CLI interface""" |
| 108 | |||
| 109 | getpass.return_value = short_value | 110 | getpass.return_value = short_value |
| 110 | urandom.return_value = non_random_bytes_57 | 111 | urandom.return_value = non_random_bytes_57 |
| 111 | with unittest.mock.patch.dict( | 112 | with unittest.mock.patch.dict( |
| @@ -119,20 +120,26 @@ def test_encrypt_without_echo( | |||
| 119 | ) | 120 | ) |
| 120 | 121 | ||
| 121 | 122 | ||
| 122 | def test_fetch_encrypted_value(config_file, master_password, short_value): | 123 | def test_fetch_encrypted_value( |
| 124 | config_file: str, master_password: str, short_value: str | ||
| 125 | ) -> None: | ||
| 123 | """Tests decryption of encrypted value""" | 126 | """Tests decryption of encrypted value""" |
| 124 | |||
| 125 | with unittest.mock.patch.dict( | 127 | with unittest.mock.patch.dict( |
| 126 | os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} | 128 | os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} |
| 127 | ): | 129 | ): |
| 128 | assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None | 130 | assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None |
| 129 | main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) | 131 | with pytest.raises(SystemExit) as exit_info: |
| 132 | main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) | ||
| 133 | assert exit_info.value.code == 0 | ||
| 130 | assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == short_value | 134 | assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == short_value |
| 131 | 135 | ||
| 132 | 136 | ||
| 133 | def test_list(capsys, config_file, nonexistent_config_file): | 137 | def test_list( |
| 138 | capsys: pytest.CaptureFixture, | ||
| 139 | config_file: str, | ||
| 140 | nonexistent_config_file: str, | ||
| 141 | ) -> None: | ||
| 134 | """Tests list via the CLI interface""" | 142 | """Tests list via the CLI interface""" |
| 135 | |||
| 136 | with pytest.raises(SystemExit) as exit_info: | 143 | with pytest.raises(SystemExit) as exit_info: |
| 137 | main(['-c', nonexistent_config_file, '-l']) | 144 | main(['-c', nonexistent_config_file, '-l']) |
| 138 | assert exit_info.value.code == errno.EIO | 145 | assert exit_info.value.code == errno.EIO |
| @@ -142,9 +149,8 @@ def test_list(capsys, config_file, nonexistent_config_file): | |||
| 142 | assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' | 149 | assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' |
| 143 | 150 | ||
| 144 | 151 | ||
| 145 | def test_help(capsys): | 152 | def test_help(capsys: pytest.CaptureFixture) -> None: |
| 146 | """Dummy test checking if the CLI is available at all""" | 153 | """Dummy test checking if the CLI is available at all""" |
| 147 | |||
| 148 | with pytest.raises(SystemExit) as exit_info: | 154 | with pytest.raises(SystemExit) as exit_info: |
| 149 | main(['-h']) | 155 | main(['-h']) |
| 150 | assert exit_info.value.code == 0 | 156 | assert exit_info.value.code == 0 |
| @@ -154,10 +160,12 @@ def test_help(capsys): | |||
| 154 | @unittest.mock.patch('os.urandom') | 160 | @unittest.mock.patch('os.urandom') |
| 155 | @unittest.mock.patch('getpass.getpass') | 161 | @unittest.mock.patch('getpass.getpass') |
| 156 | def test_generate_master_password_hash( | 162 | def test_generate_master_password_hash( |
| 157 | getpass, urandom, capsys, non_random_bytes_32 | 163 | getpass: unittest.mock.MagicMock, |
| 158 | ): | 164 | urandom: unittest.mock.MagicMock, |
| 165 | capsys: pytest.CaptureFixture, | ||
| 166 | non_random_bytes_32: bytes, | ||
| 167 | ) -> None: | ||
| 159 | """Tests master password hash generation via the CLI interface""" | 168 | """Tests master password hash generation via the CLI interface""" |
| 160 | |||
| 161 | getpass.return_value = 'The very secret passwd' | 169 | getpass.return_value = 'The very secret passwd' |
| 162 | urandom.return_value = non_random_bytes_32 | 170 | urandom.return_value = non_random_bytes_32 |
| 163 | with pytest.raises(SystemExit) as exit_info: | 171 | with pytest.raises(SystemExit) as exit_info: |
| @@ -169,9 +177,8 @@ def test_generate_master_password_hash( | |||
| 169 | ) | 177 | ) |
| 170 | 178 | ||
| 171 | 179 | ||
| 172 | def test_version(capsys): | 180 | def test_version(capsys: pytest.CaptureFixture) -> None: |
| 173 | """Dummy test checking if the CLI is available at all""" | 181 | """Dummy test checking if the CLI is available at all""" |
| 174 | |||
| 175 | with pytest.raises(SystemExit) as exit_info: | 182 | with pytest.raises(SystemExit) as exit_info: |
| 176 | main(['-v']) | 183 | main(['-v']) |
| 177 | assert exit_info.value.code == 0 | 184 | assert exit_info.value.code == 0 |
diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py index 5137e9f..3235736 100644 --- a/tests/test_envtoolkit_instance.py +++ b/tests/test_envtoolkit_instance.py | |||
| @@ -20,9 +20,8 @@ import pytest | |||
| 20 | import etoolkit | 20 | import etoolkit |
| 21 | 21 | ||
| 22 | 22 | ||
| 23 | def test_instantiation(config_data): | 23 | def test_instantiation(config_data: dict) -> None: |
| 24 | """Tests for object instatiation""" | 24 | """Tests for object instatiation""" |
| 25 | |||
| 26 | with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: | 25 | with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: |
| 27 | instance = etoolkit.EtoolkitInstance('devv', config_data) | 26 | instance = etoolkit.EtoolkitInstance('devv', config_data) |
| 28 | assert exc_info.type is etoolkit.EtoolkitInstanceError | 27 | assert exc_info.type is etoolkit.EtoolkitInstanceError |
| @@ -43,10 +42,12 @@ def test_instantiation(config_data): | |||
| 43 | 42 | ||
| 44 | 43 | ||
| 45 | def test_get_environ( | 44 | def test_get_environ( |
| 46 | config_data, master_password, short_value, wrong_master_password | 45 | config_data: dict, |
| 47 | ): | 46 | master_password: str, |
| 47 | short_value: str, | ||
| 48 | wrong_master_password: str, | ||
| 49 | ) -> None: | ||
| 48 | """Tests the EtoolkitInstance.get_environ method""" | 50 | """Tests the EtoolkitInstance.get_environ method""" |
| 49 | |||
| 50 | instance = etoolkit.EtoolkitInstance('secret', config_data) | 51 | instance = etoolkit.EtoolkitInstance('secret', config_data) |
| 51 | with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: | 52 | with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: |
| 52 | env = instance.get_environ() | 53 | env = instance.get_environ() |
| @@ -68,16 +69,14 @@ def test_get_environ( | |||
| 68 | assert 'ETOOLKIT_TEST_PASSWORD2' in instance.sensitive_env_variables | 69 | assert 'ETOOLKIT_TEST_PASSWORD2' in instance.sensitive_env_variables |
| 69 | 70 | ||
| 70 | 71 | ||
| 71 | def test_get_full_name(config_data): | 72 | def test_get_full_name(config_data: dict) -> None: |
| 72 | """Tests the EtoolkitInstance.get_full_name method""" | 73 | """Tests the EtoolkitInstance.get_full_name method""" |
| 73 | |||
| 74 | instance = etoolkit.EtoolkitInstance('secret', config_data) | 74 | instance = etoolkit.EtoolkitInstance('secret', config_data) |
| 75 | assert instance.get_full_name('->') == '_default->secret' | 75 | assert instance.get_full_name('->') == '_default->secret' |
| 76 | 76 | ||
| 77 | 77 | ||
| 78 | def test_parent_vars(config_data): | 78 | def test_parent_vars(config_data: dict) -> None: |
| 79 | """Tests the EtoolkitInstance.get_full_name method""" | 79 | """Tests the EtoolkitInstance.get_full_name method""" |
| 80 | |||
| 81 | instance = etoolkit.EtoolkitInstance('dev', config_data) | 80 | instance = etoolkit.EtoolkitInstance('dev', config_data) |
| 82 | assert instance.get_full_name('->') == '_default->dev' | 81 | assert instance.get_full_name('->') == '_default->dev' |
| 83 | assert instance.get_environ()['ETOOLKIT_TEST_PYTHONPATH'] == ( | 82 | assert instance.get_environ()['ETOOLKIT_TEST_PYTHONPATH'] == ( |
diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py index e13d439..8c21a0f 100644 --- a/tests/test_envtoolkit_instance_static.py +++ b/tests/test_envtoolkit_instance_static.py | |||
| @@ -23,9 +23,10 @@ import etoolkit | |||
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | @unittest.mock.patch('getpass.getpass') | 25 | @unittest.mock.patch('getpass.getpass') |
| 26 | def test_confirm_password_prompt(getpass, password_hash, master_password): | 26 | def test_confirm_password_prompt( |
| 27 | getpass: unittest.mock.MagicMock, password_hash: str, master_password: str | ||
| 28 | ) -> None: | ||
| 27 | """Tests the static EtoolkitInstance.confirm_password_prompt method""" | 29 | """Tests the static EtoolkitInstance.confirm_password_prompt method""" |
| 28 | |||
| 29 | getpass.return_value = master_password | 30 | getpass.return_value = master_password |
| 30 | assert ( | 31 | assert ( |
| 31 | etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash) | 32 | etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash) |
| @@ -37,9 +38,10 @@ def test_confirm_password_prompt(getpass, password_hash, master_password): | |||
| 37 | ) | 38 | ) |
| 38 | 39 | ||
| 39 | 40 | ||
| 40 | def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value): | 41 | def test_decrypt_v1( |
| 42 | master_password: str, short_encrypted_value_v1: str, short_value: str | ||
| 43 | ) -> None: | ||
| 41 | """Tests the static EtoolkitInstance.decrypt method""" | 44 | """Tests the static EtoolkitInstance.decrypt method""" |
| 42 | |||
| 43 | assert ( | 45 | assert ( |
| 44 | etoolkit.EtoolkitInstance.decrypt( | 46 | etoolkit.EtoolkitInstance.decrypt( |
| 45 | master_password, short_encrypted_value_v1 | 47 | master_password, short_encrypted_value_v1 |
| @@ -59,10 +61,9 @@ def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value): | |||
| 59 | 61 | ||
| 60 | 62 | ||
| 61 | def test_decrypt_v2_no_padding( | 63 | def test_decrypt_v2_no_padding( |
| 62 | master_password, long_encrypted_value, long_value | 64 | master_password: str, long_encrypted_value: str, long_value: str |
| 63 | ): | 65 | ) -> None: |
| 64 | """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" | 66 | """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" |
| 65 | |||
| 66 | assert ( | 67 | assert ( |
| 67 | etoolkit.EtoolkitInstance.decrypt( | 68 | etoolkit.EtoolkitInstance.decrypt( |
| 68 | master_password, long_encrypted_value | 69 | master_password, long_encrypted_value |
| @@ -80,10 +81,9 @@ def test_decrypt_v2_no_padding( | |||
| 80 | 81 | ||
| 81 | 82 | ||
| 82 | def test_decrypt_v2_with_padding( | 83 | def test_decrypt_v2_with_padding( |
| 83 | master_password, short_encrypted_value, short_value | 84 | master_password: str, short_encrypted_value: str, short_value: str |
| 84 | ): | 85 | ) -> None: |
| 85 | """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" | 86 | """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" |
| 86 | |||
| 87 | assert ( | 87 | assert ( |
| 88 | etoolkit.EtoolkitInstance.decrypt( | 88 | etoolkit.EtoolkitInstance.decrypt( |
| 89 | master_password, short_encrypted_value | 89 | master_password, short_encrypted_value |
| @@ -99,9 +99,8 @@ def test_decrypt_v2_with_padding( | |||
| 99 | assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' | 99 | assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' |
| 100 | 100 | ||
| 101 | 101 | ||
| 102 | def test_encrypt_no_padding(master_password, long_value): | 102 | def test_encrypt_no_padding(master_password: str, long_value: str) -> None: |
| 103 | """Tests the static EtoolkitInstance.encrypt method with a long string""" | 103 | """Tests the static EtoolkitInstance.encrypt method with a long string""" |
| 104 | |||
| 105 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) | 104 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) |
| 106 | assert edata.startswith('enc-val$2$') | 105 | assert edata.startswith('enc-val$2$') |
| 107 | assert len(edata) == 131 | 106 | assert len(edata) == 131 |
| @@ -111,9 +110,8 @@ def test_encrypt_no_padding(master_password, long_value): | |||
| 111 | ) | 110 | ) |
| 112 | 111 | ||
| 113 | 112 | ||
| 114 | def test_encrypt_with_padding(master_password, short_value): | 113 | def test_encrypt_with_padding(master_password: str, short_value: str) -> None: |
| 115 | """Tests the static EtoolkitInstance.encrypt method with a short string""" | 114 | """Tests the static EtoolkitInstance.encrypt method with a short string""" |
| 116 | |||
| 117 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) | 115 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) |
| 118 | assert edata.startswith('enc-val$2$') | 116 | assert edata.startswith('enc-val$2$') |
| 119 | assert len(edata) == 123 | 117 | assert len(edata) == 123 |
| @@ -125,14 +123,13 @@ def test_encrypt_with_padding(master_password, short_value): | |||
| 125 | 123 | ||
| 126 | @unittest.mock.patch('os.urandom') | 124 | @unittest.mock.patch('os.urandom') |
| 127 | def test_encrypt_staticly_no_padding( | 125 | def test_encrypt_staticly_no_padding( |
| 128 | urandom, | 126 | urandom: unittest.mock.MagicMock, |
| 129 | master_password, | 127 | master_password: str, |
| 130 | non_random_bytes_32, | 128 | non_random_bytes_32: bytes, |
| 131 | long_encrypted_value, | 129 | long_encrypted_value: str, |
| 132 | long_value, | 130 | long_value: str, |
| 133 | ): | 131 | ) -> None: |
| 134 | """Tests the EtoolkitInstance.encrypt method always with the same salt""" | 132 | """Tests the EtoolkitInstance.encrypt method always with the same salt""" |
| 135 | |||
| 136 | urandom.return_value = non_random_bytes_32 | 133 | urandom.return_value = non_random_bytes_32 |
| 137 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) | 134 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) |
| 138 | assert edata == long_encrypted_value | 135 | assert edata == long_encrypted_value |
| @@ -144,14 +141,13 @@ def test_encrypt_staticly_no_padding( | |||
| 144 | 141 | ||
| 145 | @unittest.mock.patch('os.urandom') | 142 | @unittest.mock.patch('os.urandom') |
| 146 | def test_encrypt_staticly_with_padding( | 143 | def test_encrypt_staticly_with_padding( |
| 147 | urandom, | 144 | urandom: unittest.mock.MagicMock, |
| 148 | master_password, | 145 | master_password: str, |
| 149 | non_random_bytes_57, | 146 | non_random_bytes_57: bytes, |
| 150 | short_encrypted_value, | 147 | short_encrypted_value: str, |
| 151 | short_value, | 148 | short_value: str, |
| 152 | ): | 149 | ) -> None: |
| 153 | """Tests the EtoolkitInstance.encrypt method always with the same salt""" | 150 | """Tests the EtoolkitInstance.encrypt method always with the same salt""" |
| 154 | |||
| 155 | urandom.return_value = non_random_bytes_57 | 151 | urandom.return_value = non_random_bytes_57 |
| 156 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) | 152 | edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) |
| 157 | assert edata == short_encrypted_value | 153 | assert edata == short_encrypted_value |
| @@ -160,9 +156,8 @@ def test_encrypt_staticly_with_padding( | |||
| 160 | ) | 156 | ) |
| 161 | 157 | ||
| 162 | 158 | ||
| 163 | def test_get_new_password_hash(master_password): | 159 | def test_get_new_password_hash(master_password: str) -> None: |
| 164 | """Tests the static EtoolkitInstance.get_new_password_hash method""" | 160 | """Tests the static EtoolkitInstance.get_new_password_hash method""" |
| 165 | |||
| 166 | new_hash = etoolkit.EtoolkitInstance.get_new_password_hash(master_password) | 161 | new_hash = etoolkit.EtoolkitInstance.get_new_password_hash(master_password) |
| 167 | # all pbkdf2 params are the same / hardcoded for the time being | 162 | # all pbkdf2 params are the same / hardcoded for the time being |
| 168 | assert new_hash.startswith('pbkdf2_sha256$500000$') | 163 | assert new_hash.startswith('pbkdf2_sha256$500000$') |
| @@ -173,9 +168,8 @@ def test_get_new_password_hash(master_password): | |||
| 173 | ) | 168 | ) |
| 174 | 169 | ||
| 175 | 170 | ||
| 176 | def test_parse_value(): | 171 | def test_parse_value() -> None: |
| 177 | """Tests the static EtoolkitInstance.parse_value method""" | 172 | """Tests the static EtoolkitInstance.parse_value method""" |
| 178 | |||
| 179 | assert ( | 173 | assert ( |
| 180 | etoolkit.EtoolkitInstance.parse_value('t%bs%t', {'%b': 'e', '%t': 't'}) | 174 | etoolkit.EtoolkitInstance.parse_value('t%bs%t', {'%b': 'e', '%t': 't'}) |
| 181 | == 'test' | 175 | == 'test' |
| @@ -183,10 +177,9 @@ def test_parse_value(): | |||
| 183 | 177 | ||
| 184 | 178 | ||
| 185 | def test_password_matches( | 179 | def test_password_matches( |
| 186 | password_hash, master_password, wrong_master_password | 180 | password_hash: str, master_password: str, wrong_master_password: str |
| 187 | ): | 181 | ) -> None: |
| 188 | """Tests the static EtoolkitInstance.password_matches method""" | 182 | """Tests the static EtoolkitInstance.password_matches method""" |
| 189 | |||
| 190 | assert etoolkit.EtoolkitInstance.password_matches( | 183 | assert etoolkit.EtoolkitInstance.password_matches( |
| 191 | master_password, password_hash | 184 | master_password, password_hash |
| 192 | ) | 185 | ) |
| @@ -197,16 +190,15 @@ def test_password_matches( | |||
| 197 | 190 | ||
| 198 | @unittest.mock.patch('os.urandom') | 191 | @unittest.mock.patch('os.urandom') |
| 199 | def test_reencrypt_staticly_with_padding( | 192 | def test_reencrypt_staticly_with_padding( |
| 200 | urandom, | 193 | urandom: unittest.mock.MagicMock, |
| 201 | master_password, | 194 | master_password: str, |
| 202 | new_master_password, | 195 | new_master_password: str, |
| 203 | non_random_bytes_57, | 196 | non_random_bytes_57: bytes, |
| 204 | short_encrypted_value, | 197 | short_encrypted_value: str, |
| 205 | short_encrypted_value_v1, | 198 | short_encrypted_value_v1: str, |
| 206 | short_value, | 199 | short_value: str, |
| 207 | ): | 200 | ) -> None: |
| 208 | """Tests the EtoolkitInstance.reencrypt method always with the same salt""" | 201 | """Tests the EtoolkitInstance.reencrypt method always with the same salt""" |
| 209 | |||
| 210 | urandom.return_value = non_random_bytes_57 | 202 | urandom.return_value = non_random_bytes_57 |
| 211 | # reencrypt (migrate) v1 to current using the same password | 203 | # reencrypt (migrate) v1 to current using the same password |
| 212 | edata = etoolkit.EtoolkitInstance.reencrypt( | 204 | edata = etoolkit.EtoolkitInstance.reencrypt( |
