git.schokokeks.org
Repositories
Help
Report an Issue
derivepassphrase.git
Code
Commits
Branches
Tags
Suche
Strukturansicht:
c5c882a
Branches
Tags
documentation-tree
master
unstable/modularize-and-refactor-test-machinery
unstable/ssh-agent-socket-providers
wishlist
0.1.0
0.1.1
0.1.2
0.1.3
0.2.0
0.3.0
0.3.1
0.3.2
0.3.3
0.4.0
0.5.1
0.5.2
derivepassphrase.git
src
derivepassphrase
cli.py
Comment on coverage exclusions, in shorthand
Marco Ricci
commited
c5c882a
at 2025-07-26 17:35:21
cli.py
Blame
History
Raw
# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> # # SPDX-License-Identifier: Zlib # ruff: noqa: TRY400 """Command-line interface for derivepassphrase.""" from __future__ import annotations import base64 import collections import contextlib import json import logging import os from typing import ( TYPE_CHECKING, Final, Literal, NoReturn, TextIO, cast, ) import click import click.shell_completion from typing_extensions import ( Any, ) from derivepassphrase import _internals, _types, exporter, ssh_agent, vault from derivepassphrase._internals import cli_helpers, cli_machinery from derivepassphrase._internals import cli_messages as _msg if TYPE_CHECKING: from collections.abc import Sequence from collections.abc import Set as AbstractSet from contextlib import AbstractContextManager __all__ = ('derivepassphrase',) PROG_NAME = _internals.PROG_NAME VERSION = _internals.VERSION @click.group( context_settings={ 'help_option_names': ['-h', '--help'], 'ignore_unknown_options': True, 'allow_interspersed_args': False, }, epilog=_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EPILOG_01), invoke_without_command=True, cls=cli_machinery.TopLevelCLIEntryPoint, help=( _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_01), _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_02), _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_03), ), ) @cli_machinery.version_option( cli_machinery.derivepassphrase_version_option_callback ) @cli_machinery.color_forcing_pseudo_option @cli_machinery.standard_logging_options @click.pass_context def derivepassphrase(ctx: click.Context, /) -> None: """Derive a strong passphrase, deterministically, from a master secret. This is a [`click`][CLICK]-powered command-line interface function, and not intended for programmatic use. See the derivepassphrase(1) manpage for full documentation of the interface. (See also [`click.testing.CliRunner`][] for controlled, programmatic invocation.) [CLICK]: https://pypi.org/package/click/ """ # TODO(the-13th-letter): Turn this callback into a no-op in v1.0. # https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands deprecation = logging.getLogger(f'{PROG_NAME}.deprecation') if ctx.invoked_subcommand is None: deprecation.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED ), extra={'color': ctx.color}, ) # See definition of click.Group.invoke, non-chained case. with ctx: sub_ctx = derivepassphrase_vault.make_context( 'vault', ctx.args, parent=ctx ) with sub_ctx: return derivepassphrase_vault.invoke(sub_ctx) return None # Exporter # ======== @derivepassphrase.group( 'export', context_settings={ 'help_option_names': ['-h', '--help'], 'ignore_unknown_options': True, 'allow_interspersed_args': False, }, invoke_without_command=True, cls=cli_machinery.DefaultToVaultGroup, help=( _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_01), _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_02), _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_03), ), ) @cli_machinery.version_option(cli_machinery.export_version_option_callback) @cli_machinery.color_forcing_pseudo_option @cli_machinery.standard_logging_options @click.pass_context def derivepassphrase_export(ctx: click.Context, /) -> None: """Export a foreign configuration to standard output. This is a [`click`][CLICK]-powered command-line interface function, and not intended for programmatic use. See the derivepassphrase-export(1) manpage for full documentation of the interface. (See also [`click.testing.CliRunner`][] for controlled, programmatic invocation.) [CLICK]: https://pypi.org/package/click/ """ # TODO(the-13th-letter): Turn this callback into a no-op in v1.0. # https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands deprecation = logging.getLogger(f'{PROG_NAME}.deprecation') if ctx.invoked_subcommand is None: deprecation.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED ), extra={'color': ctx.color}, ) # See definition of click.Group.invoke, non-chained case. with ctx: sub_ctx = derivepassphrase_export_vault.make_context( 'vault', ctx.args, parent=ctx ) # Constructing the subcontext above will usually already # lead to a click.UsageError, so this block typically won't # actually be called. with sub_ctx: # pragma: no cover [unused] return derivepassphrase_export_vault.invoke(sub_ctx) return None @derivepassphrase_export.command( 'vault', context_settings={'help_option_names': ['-h', '--help']}, cls=cli_machinery.CommandWithHelpGroups, help=( _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_01), _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_02, path_metavar=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_METAVAR_PATH, ), ), _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_03, path_metavar=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_METAVAR_PATH, ), ), ), ) @click.option( '-f', '--format', 'formats', metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_FORMAT_METAVAR_FMT), multiple=True, default=('v0.3', 'v0.2', 'storeroom'), type=click.Choice(['v0.2', 'v0.3', 'storeroom']), help=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_FORMAT_HELP_TEXT, defaults_hint=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_FORMAT_DEFAULTS_HELP_TEXT, ), metavar=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_FORMAT_METAVAR_FMT, ), ), cls=cli_machinery.StandardOption, ) @click.option( '-k', '--key', metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_KEY_METAVAR_K), help=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_KEY_HELP_TEXT, metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_KEY_METAVAR_K), defaults_hint=_msg.TranslatedString( _msg.Label.EXPORT_VAULT_KEY_DEFAULTS_HELP_TEXT, ), ), cls=cli_machinery.StandardOption, ) @cli_machinery.version_option( cli_machinery.export_vault_version_option_callback ) @cli_machinery.color_forcing_pseudo_option @cli_machinery.standard_logging_options @click.argument( 'path', metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_METAVAR_PATH), required=True, shell_complete=cli_helpers.shell_complete_path, ) @click.pass_context def derivepassphrase_export_vault( ctx: click.Context, /, *, path: str | bytes | os.PathLike[str] | None, formats: Sequence[Literal['v0.2', 'v0.3', 'storeroom']] = (), key: str | bytes | None = None, ) -> None: """Export a vault-native configuration to standard output. This is a [`click`][CLICK]-powered command-line interface function, and not intended for programmatic use. See the derivepassphrase-export-vault(1) manpage for full documentation of the interface. (See also [`click.testing.CliRunner`][] for controlled, programmatic invocation.) [CLICK]: https://pypi.org/package/click/ """ logger = logging.getLogger(PROG_NAME) if path in {'VAULT_PATH', b'VAULT_PATH'}: path = None if isinstance(key, str): # pragma: no branch key = key.encode('utf-8') for fmt in formats: try: config = exporter.export_vault_config_data(path, key, format=fmt) except ( IsADirectoryError, NotADirectoryError, exporter.NotAVaultConfigError, RuntimeError, ): logger.info( _msg.TranslatedString( _msg.InfoMsgTemplate.CANNOT_LOAD_AS_VAULT_CONFIG, path=path or exporter.get_vault_path(), fmt=fmt, ), extra={'color': ctx.color}, ) continue except OSError as exc: logger.error( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_PARSE_AS_VAULT_CONFIG_OSERROR, path=path, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), extra={'color': ctx.color}, ) ctx.exit(1) except ModuleNotFoundError: logger.error( _msg.TranslatedString( _msg.ErrMsgTemplate.MISSING_MODULE, module='cryptography', ), extra={'color': ctx.color}, ) logger.info( _msg.TranslatedString( _msg.InfoMsgTemplate.PIP_INSTALL_EXTRA, extra_name='export', ), extra={'color': ctx.color}, ) ctx.exit(1) else: if not _types.is_vault_config(config): logger.error( _msg.TranslatedString( _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG, config=config, ), extra={'color': ctx.color}, ) ctx.exit(1) click.echo( json.dumps( config, ensure_ascii=False, indent=2, sort_keys=True ), color=ctx.color, ) break else: logger.error( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_PARSE_AS_VAULT_CONFIG, path=path, ).maybe_without_filename(), extra={'color': ctx.color}, ) ctx.exit(1) class _VaultContext: # noqa: PLR0904 """The context for the "vault" command-line interface. This context object -- wrapping a [`click.Context`][] object -- encapsulates a single call to the `derivepassphrase vault` command-line. Although fully documented, this class is an implementation detail of the `derivepassphrase vault` command-line and should not be instantiated directly by users or API clients. Attributes: logger: The logger used for warnings and error messages. deprecation: The logger used for deprecation warnings. ctx: The underlying [`click.Context`][] from which the command-line settings and parameter values are queried. all_ops: A list of operations supported by the CLI, in the order that they are queried in the `click` context. The final entry is the default operation, and does not correspond to a `click` parameter; all others are `click` parameter names. readwrite_ops: A set of operations which modify the `derivepassphrase` state. All other operations are read-only. options_in_group: A mapping of option group names to lists of known options from this group. Used during the validation of the command line. params_by_str: A mapping of option names (long names, short names, etc.) to option objects. Used during the validation of the command line. """ logger: Final = logging.getLogger(PROG_NAME) """""" deprecation: Final = logging.getLogger(PROG_NAME + '.deprecation') """""" ctx: Final[click.Context] """""" all_ops: tuple[str, ...] = ( 'delete_service_settings', 'delete_globals', 'clear_all_settings', 'import_settings', 'export_settings', 'store_config_only', # The default op "derive_passphrase" must be last! 'derive_passphrase', ) readwrite_ops: AbstractSet[str] = frozenset({ 'delete_service_settings', 'delete_globals', 'clear_all_settings', 'import_settings', 'store_config_only', }) options_in_group: dict[type[click.Option], list[click.Option]] params_by_str: dict[str, click.Parameter] def __init__(self, ctx: click.Context, /) -> None: """Initialize the vault context. Args: ctx: The underlying [`click.Context`][] from which the command-line settings and parameter values are queried. """ self.ctx = ctx self.params_by_str = {} self.options_in_group = {} for param in ctx.command.params: if isinstance(param, click.Option): group: type[click.Option] known_option_groups = [ cli_machinery.PassphraseGenerationOption, cli_machinery.ConfigurationOption, cli_machinery.StorageManagementOption, cli_machinery.LoggingOption, cli_machinery.CompatibilityOption, cli_machinery.StandardOption, ] if isinstance(param, cli_machinery.OptionGroupOption): for class_ in known_option_groups: if isinstance(param, class_): group = class_ break else: # pragma: no cover [failsafe] assert False, f'Unknown option group for {param!r}' # noqa: B011,PT015 else: group = click.Option self.options_in_group.setdefault(group, []).append(param) self.params_by_str[param.human_readable_name] = param for name in param.opts + param.secondary_opts: self.params_by_str[name] = param def is_param_set(self, param: click.Parameter, /) -> bool: """Return true if the parameter is set.""" return bool(self.ctx.params.get(param.human_readable_name)) def option_name(self, param: click.Parameter | str, /) -> str: """Return the option name of a parameter. Annoyingly, `param.human_readable_name` contains the *function* parameter name, not the list of option names. *Those* are stashed in the `.opts` and `.secondary_opts` attributes, which are visible in the `.to_info_dict()` output, but not otherwise documented. We return the shortest one among the long-form option names. Args: param: The parameter whose option name is requested. Raises: ValueError: The parameter has no long-form option names. """ param = self.params_by_str[param] if isinstance(param, str) else param names = [param.human_readable_name, *param.opts, *param.secondary_opts] option_names = [n for n in names if n.startswith('--')] return min(option_names, key=len) def check_incompatible_options( self, param1: click.Parameter | str, param2: click.Parameter | str, ) -> None: """Raise an error if the two options are incompatible. Raises: click.BadOptionUsage: The given options are incompatible. """ param1 = ( self.params_by_str[param1] if isinstance(param1, str) else param1 ) param2 = ( self.params_by_str[param2] if isinstance(param2, str) else param2 ) if param1 == param2: return if not self.is_param_set(param1): return if self.is_param_set(param2): param1_str = self.option_name(param1) param2_str = self.option_name(param2) raise click.BadOptionUsage( param1_str, str( _msg.TranslatedString( _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE, param1=param1_str, param2=param2_str, ) ), ctx=self.ctx, ) return def err(self, msg: Any, /, **kwargs: Any) -> NoReturn: # noqa: ANN401 """Log an error, then abort the function call. We ensure that color handling is done properly before the error is logged. """ stacklevel = kwargs.pop('stacklevel', 1) stacklevel += 1 extra = kwargs.pop('extra', {}) extra.setdefault('color', self.ctx.color) self.logger.error(msg, stacklevel=stacklevel, extra=extra, **kwargs) self.ctx.exit(1) def warning(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 """Log a warning. We ensure that color handling is done properly before the warning is logged. """ stacklevel = kwargs.pop('stacklevel', 1) stacklevel += 1 extra = kwargs.pop('extra', {}) extra.setdefault('color', self.ctx.color) self.logger.warning(msg, stacklevel=stacklevel, extra=extra, **kwargs) def deprecation_warning(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 """Log a deprecation warning. We ensure that color handling is done properly before the warning is logged. """ stacklevel = kwargs.pop('stacklevel', 1) stacklevel += 1 extra = kwargs.pop('extra', {}) extra.setdefault('color', self.ctx.color) self.deprecation.warning( msg, stacklevel=stacklevel, extra=extra, **kwargs ) def deprecation_info(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 """Log a deprecation info message. We ensure that color handling is done properly before the warning is logged. """ stacklevel = kwargs.pop('stacklevel', 1) stacklevel += 1 extra = kwargs.pop('extra', {}) extra.setdefault('color', self.ctx.color) self.deprecation.info( msg, stacklevel=stacklevel, extra=extra, **kwargs ) def get_config(self) -> _types.VaultConfig: """Return the vault configuration stored on disk. If no configuration is stored, return an empty configuration. If only a v0.1-style configuration is found, attempt to migrate it. This will be removed in v1.0. """ try: return cli_helpers.load_config() except FileNotFoundError: # TODO(the-13th-letter): Return the empty default # configuration directly. # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file try: backup_config, exc = cli_helpers.migrate_and_load_old_config() except FileNotFoundError: return {'services': {}} old_name = cli_helpers.config_filename( subsystem='old settings.json' ).name new_name = cli_helpers.config_filename(subsystem='vault').name self.deprecation_warning( _msg.TranslatedString( _msg.WarnMsgTemplate.V01_STYLE_CONFIG, old=old_name, new=new_name, ), ) if isinstance(exc, OSError): self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG, path=new_name, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) else: self.deprecation_info( _msg.TranslatedString( _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED, path=new_name, ), ) return backup_config except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) except Exception as exc: # noqa: BLE001 self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, error=str(exc), filename=None, ).maybe_without_filename(), exc_info=exc, ) def put_config(self, config: _types.VaultConfig, /) -> None: """Store the given vault configuration to disk.""" try: cli_helpers.save_config(config) except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) except Exception as exc: # noqa: BLE001 self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, error=str(exc), filename=None, ).maybe_without_filename(), exc_info=exc, ) def get_user_config(self) -> dict[str, Any]: """Return the global user configuration stored on disk. If no configuration is stored, return an empty configuration. """ try: return cli_helpers.load_user_config() except FileNotFoundError: return {} except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) except Exception as exc: # noqa: BLE001 self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, error=str(exc), filename=None, ).maybe_without_filename(), exc_info=exc, ) def validate_command_line(self) -> None: # noqa: C901,PLR0912 """Check for missing/extra arguments and conflicting options. Raises: click.UsageError: The command-line is invalid because of missing or extra arguments, or because of conflicting options. The error message contains further details. """ param: click.Parameter self.check_incompatible_options('--phrase', '--key') for group in ( cli_machinery.ConfigurationOption, cli_machinery.StorageManagementOption, ): for opt in self.options_in_group[group]: if opt not in { self.params_by_str['--config'], self.params_by_str['--notes'], }: for other_opt in self.options_in_group[ cli_machinery.PassphraseGenerationOption ]: self.check_incompatible_options(opt, other_opt) for group in ( cli_machinery.ConfigurationOption, cli_machinery.StorageManagementOption, ): for opt in self.options_in_group[group]: for other_opt in self.options_in_group[ cli_machinery.ConfigurationOption ]: if {opt, other_opt} != { self.params_by_str['--config'], self.params_by_str['--notes'], }: self.check_incompatible_options(opt, other_opt) for other_opt in self.options_in_group[ cli_machinery.StorageManagementOption ]: self.check_incompatible_options(opt, other_opt) service: str | None = self.ctx.params['service'] service_metavar = _msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ) sv_or_global_options = self.options_in_group[ cli_machinery.PassphraseGenerationOption ] for param in sv_or_global_options: if self.is_param_set(param) and not ( service is not None or self.is_param_set(self.params_by_str['--config']) ): err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG, param=param.opts[0], service_metavar=service_metavar, ) raise click.UsageError(str(err_msg)) sv_options = [ self.params_by_str['--notes'], self.params_by_str['--delete'], ] for param in sv_options: if self.is_param_set(param) and not service is not None: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE, param=param.opts[0], service_metavar=service_metavar, ) raise click.UsageError(str(err_msg)) no_sv_options = [ self.params_by_str['--delete-globals'], self.params_by_str['--clear'], *self.options_in_group[cli_machinery.StorageManagementOption], ] for param in no_sv_options: if self.is_param_set(param) and service is not None: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.PARAMS_NO_SERVICE, param=param.opts[0], service_metavar=service_metavar, ) raise click.UsageError(str(err_msg)) def get_mutex( self, op: str, ) -> AbstractContextManager[AbstractContextManager | None]: """Return a mutex for accessing the configuration on disk. The mutex is a context manager, and will lock out other threads and processes attempting to access the configuration in an incompatible manner. Returns: If the requested operation is a read-only operation, return a no-op mutex. (Concurrent reads are always allowed, even in the presence of writers.) Otherwise, for read-write operations, return an actual mutex. """ return ( cli_helpers.configuration_mutex() if op in self.readwrite_ops else contextlib.nullcontext() ) def dispatch_op(self) -> None: """Dispatch to the handler matching the command-line call. Also issue any appropriate warnings about the command-line, e.g., incompatibilities with vault(1) or ineffective options. """ service_metavar = _msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ) if self.ctx.params['service'] == '': # noqa: PLC1901 self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED, service_metavar=service_metavar, ), ) if self.ctx.params.get('edit_notes') and not self.ctx.params.get( 'store_config_only' ): self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.EDITING_NOTES_BUT_NOT_STORING_CONFIG, service_metavar=service_metavar, ), ) op: str for candidate_op in self.all_ops[:-1]: if self.ctx.params.get(candidate_op): op = candidate_op break else: op = self.all_ops[-1] with self.get_mutex(op): op_func = getattr(self, 'run_op_' + op) return op_func() def run_op_delete_service_settings(self) -> None: """Delete settings for a specific service.""" service = self.ctx.params['service'] assert service is not None configuration = self.get_config() if service in configuration['services']: del configuration['services'][service] self.put_config(configuration) def run_op_delete_globals(self) -> None: """Delete the global settings.""" configuration = self.get_config() if 'global' in configuration: del configuration['global'] self.put_config(configuration) def run_op_clear_all_settings(self) -> None: """Clear all settings.""" self.put_config({'services': {}}) def run_op_import_settings(self) -> None: # noqa: C901,PLR0912 """Import settings from a given file. Issue multiple warnings, if appropriate, e.g. for Unicode normalization issues with stored passphrases or conflicting stored passphrases and keys. Respect the `--overwrite-config` and `--merge-config` options when writing the imported configuration to disk. """ service_metavar = _msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ) user_config = self.get_user_config() import_settings = self.ctx.params['import_settings'] overwrite_config = self.ctx.params['overwrite_config'] try: # TODO(the-13th-letter): keep track of auto-close; try # os.dup if feasible infile = cast( 'TextIO', ( import_settings if hasattr(import_settings, 'close') else click.open_file(os.fspath(import_settings), 'rt') ), ) # Don't specifically catch TypeError or ValueError here if # the passed-in fileobj is not a readable text stream. This # will never happen on the command-line (thanks to `click`), # and for programmatic use, our caller may want accurate # error information. with infile: maybe_config = json.load(infile) except json.JSONDecodeError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS, error=exc, ) ) except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, error=exc.strerror, filename=exc.filename, ).maybe_without_filename() ) cleaned = _types.clean_up_falsy_vault_config_values(maybe_config) if not _types.is_vault_config(maybe_config): self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, error=_msg.TranslatedString( _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG, config=maybe_config, ), filename=None, ).maybe_without_filename() ) assert cleaned is not None for step in cleaned: # These are never fatal errors, because the semantics of # vault upon encountering these settings are ill-specified, # but not ill-defined. if step.action == 'replace': self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE, old=json.dumps(step.old_value), path=_types.json_path(step.path), new=json.dumps(step.new_value), ), ) else: self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE, path=_types.json_path(step.path), old=json.dumps(step.old_value), ), ) if '' in maybe_config['services']: self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE, service_metavar=service_metavar, PROG_NAME=PROG_NAME, ), ) for service_name in sorted(maybe_config['services'].keys()): if not cli_helpers.is_completable_item(service_name): self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, service=service_name, ), ) try: cli_helpers.check_for_misleading_passphrase( ('global',), cast('dict[str, Any]', maybe_config.get('global', {})), main_config=user_config, ctx=self.ctx, ) for key, value in maybe_config['services'].items(): cli_helpers.check_for_misleading_passphrase( ('services', key), cast('dict[str, Any]', value), main_config=user_config, ctx=self.ctx, ) except AssertionError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.INVALID_USER_CONFIG, error=exc, filename=None, ).maybe_without_filename(), ) global_obj = maybe_config.get('global', {}) has_key = _types.js_truthiness(global_obj.get('key')) has_phrase = _types.js_truthiness(global_obj.get('phrase')) if has_key and has_phrase: self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE, ), ) for service_name, service_obj in maybe_config['services'].items(): has_key = _types.js_truthiness( service_obj.get('key') ) or _types.js_truthiness(global_obj.get('key')) has_phrase = _types.js_truthiness( service_obj.get('phrase') ) or _types.js_truthiness(global_obj.get('phrase')) if has_key and has_phrase: self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, service=json.dumps(service_name), ), ) if overwrite_config: self.put_config(maybe_config) else: configuration = self.get_config() merged_config: collections.ChainMap[str, Any] = ( collections.ChainMap( { 'services': collections.ChainMap( maybe_config['services'], configuration['services'], ), }, {'global': maybe_config['global']} if 'global' in maybe_config else {}, {'global': configuration['global']} if 'global' in configuration else {}, ) ) new_config: Any = { k: dict(v) if isinstance(v, collections.ChainMap) else v for k, v in sorted(merged_config.items()) } assert _types.is_vault_config(new_config) self.put_config(new_config) def run_op_export_settings(self) -> None: """Export settings to a given file. Respect the `--export-as` option when writing the exported configuration to the file. """ export_settings = self.ctx.params['export_settings'] export_as = self.ctx.params['export_as'] configuration = self.get_config() try: # TODO(the-13th-letter): keep track of auto-close; try # os.dup if feasible outfile = cast( 'TextIO', ( export_settings if hasattr(export_settings, 'close') else click.open_file(os.fspath(export_settings), 'wt') ), ) # Don't specifically catch TypeError or ValueError here if # the passed-in fileobj is not a writable text stream. This # will never happen on the command-line (thanks to `click`), # and for programmatic use, our caller may want accurate # error information. with outfile: if export_as == 'sh': this_ctx = self.ctx prog_name_pieces = collections.deque([ this_ctx.info_name or 'vault', ]) while ( this_ctx.parent is not None and this_ctx.parent.info_name is not None ): prog_name_pieces.appendleft(this_ctx.parent.info_name) this_ctx = this_ctx.parent cli_helpers.print_config_as_sh_script( configuration, outfile=outfile, prog_name_list=prog_name_pieces, ) else: json.dump( configuration, outfile, ensure_ascii=False, indent=2, sort_keys=True, ) except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) def run_subop_query_phrase_or_key_change( # noqa: C901,PLR0912 self, *, empty_service_permitted: bool, configuration: _types.VaultConfig | None = None, ) -> collections.ChainMap[str, Any]: """Query the master passphrase or master SSH key, if changed. If the user indicates they want to change the master passphrase or master SSH key for this call, or if they want to configure a stored global or service-specific master passphrase or master SSH key, then query the user. This is not a complete command-line call operation in and of itself. Args: empty_service_permitted: True if an empty service name is permitted, False otherwise. configuration: The vault configuration, parsed from disk. If not given, we read the configuration from disk ourselves. The returned effective configuration already contains a relevant slice of the vault configuration. However, some callers need access to the full configuration *and* need the slice within the effective configuration to refer to the same object. Returns: The effective configuration for the (possibly empty) given service, as a [chained map][collections.ChainMap]. Any master passphrase or master SSH key overrides that may be in effect are stored in the first map. Raises: click.UsageError: The service name was empty, and an empty service name was not permitted as per the method parameters. Warning: It is the caller's responsibility to vet any interactively entered master passphrase for Unicode normalization issues. """ service = self.ctx.params['service'] use_key = self.ctx.params['use_key'] use_phrase = self.ctx.params['use_phrase'] if configuration is None: configuration = self.get_config() service_keys_on_commandline = { 'length', 'repeat', 'lower', 'upper', 'number', 'space', 'dash', 'symbol', } settings: collections.ChainMap[str, Any] = collections.ChainMap( { k: v for k in service_keys_on_commandline if (v := self.ctx.params.get(k)) is not None }, cast( 'dict[str, Any]', configuration['services'].get(service, {}) if service else {}, ), cast('dict[str, Any]', configuration.get('global', {})), ) if not service and not empty_service_permitted: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.SERVICE_REQUIRED, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ) raise click.UsageError(str(err_msg)) if use_key: try: settings.maps[0]['key'] = base64.standard_b64encode( cli_helpers.select_ssh_key(ctx=self.ctx) ).decode('ASCII') except IndexError: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION ), ) except KeyError: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND ), ) except LookupError: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS, PROG_NAME=PROG_NAME, ) ) except NotImplementedError: self.err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX)) except OSError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT, error=exc.strerror, filename=exc.filename, ).maybe_without_filename(), ) except ssh_agent.SSHAgentFailedError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS ), exc_info=exc, ) except RuntimeError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT ), exc_info=exc, ) elif use_phrase: maybe_phrase = cli_helpers.prompt_for_passphrase() if not maybe_phrase: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE ) ) else: settings.maps[0]['phrase'] = maybe_phrase return settings def run_op_store_config_only(self) -> None: # noqa: C901,PLR0912,PLR0914,PLR0915 """Update the stored vault configuration. Depending on the presence or the absence of a service name, update either the service-specific settings, or the global settings. (An empty service name is respected, i.e., updates the former.) Respect the `--unset=SETTING` option to unset the named settings, and the `--notes` option to edit notes interactively in a spawned text editor. Issue multiple warnings, if appropriate, e.g. for Unicode normalization issues with stored passphrases or conflicting stored passphrases and keys. Respect the `--overwrite-config` and `--merge-config` options when writing the imported configuration to disk, and the `--modern-editor-interface` and `--vault-legacy-editor-interface` options when editing notes. Raises: click.UsageError: The user requested that the same setting be both unset and set. """ service = self.ctx.params['service'] use_key = self.ctx.params['use_key'] use_phrase = self.ctx.params['use_phrase'] unset_settings = self.ctx.params['unset_settings'] overwrite_config = self.ctx.params['overwrite_config'] edit_notes = self.ctx.params['edit_notes'] modern_editor_interface = self.ctx.params['modern_editor_interface'] configuration = self.get_config() user_config = self.get_user_config() settings = self.run_subop_query_phrase_or_key_change( configuration=configuration, empty_service_permitted=True ) overrides = settings.maps[0] view: collections.ChainMap[str, Any] view = ( collections.ChainMap(*settings.maps[:2]) if service else collections.ChainMap(settings.maps[0], settings.maps[2]) ) if use_key: view['key'] = overrides['key'] elif use_phrase: view['phrase'] = overrides['phrase'] try: cli_helpers.check_for_misleading_passphrase( ('services', service) if service else ('global',), overrides, main_config=user_config, ctx=self.ctx, ) except AssertionError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.INVALID_USER_CONFIG, error=exc, filename=None, ).maybe_without_filename(), ) if 'key' in settings: if service: w_msg = _msg.TranslatedString( _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, service=json.dumps(service), ) else: w_msg = _msg.TranslatedString( _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE ) self.warning(w_msg) if not view.maps[0] and not unset_settings and not edit_notes: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS, settings_type=_msg.TranslatedString( _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_SERVICE if service else _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_GLOBAL # noqa: E501 ), ) raise click.UsageError(str(err_msg)) for setting in unset_settings: if setting in view.maps[0]: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING, setting=setting, ) raise click.UsageError(str(err_msg)) if not cli_helpers.is_completable_item(service): self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, service=service, ), ) subtree: dict[str, Any] = ( configuration['services'].setdefault(service, {}) # type: ignore[assignment] if service else configuration.setdefault('global', {}) ) if overwrite_config: subtree.clear() else: for setting in unset_settings: subtree.pop(setting, None) subtree.update(view) assert _types.is_vault_config(configuration), ( f'Invalid vault configuration: {configuration!r}' ) if edit_notes: assert service is not None notes_instructions = _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_INSTRUCTION_TEXT ) notes_marker = _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER ) notes_legacy_instructions = _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT ) old_notes_value = subtree.get('notes', '') if modern_editor_interface: text = '\n'.join([ str(notes_instructions), str(notes_marker), old_notes_value, ]) else: text = old_notes_value or str(notes_legacy_instructions) notes_value = click.edit(text=text, require_save=False) assert notes_value is not None if ( not modern_editor_interface and notes_value.strip() != old_notes_value.strip() ): backup_file = cli_helpers.config_filename( subsystem='notes backup' ) backup_file.write_text(old_notes_value, encoding='UTF-8') self.warning( _msg.TranslatedString( _msg.WarnMsgTemplate.LEGACY_EDITOR_INTERFACE_NOTES_BACKUP, filename=str(backup_file), ), ) subtree['notes'] = notes_value.strip() elif ( modern_editor_interface and notes_value.strip() != text.strip() ): notes_lines = collections.deque( notes_value.splitlines(keepends=True) ) while notes_lines: line = notes_lines.popleft() if line.startswith(str(notes_marker)): notes_value = ''.join(notes_lines) break else: if not notes_value.strip(): self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.USER_ABORTED_EDIT ) ) subtree['notes'] = notes_value.strip() self.put_config(configuration) def run_op_derive_passphrase(self) -> None: """Derive a passphrase. Derive a service passphrase using the effective settings from both the command-line and the stored configuration. If any service notes are stored for this service, print them as well. (An empty service name is permitted, though discouraged for compatibility reasons.) Issue a warning (if appropriate) for Unicode normalization issues with the interactive passphrase. Respect the `--print-notes-before` and `--print-notes-after` options when printing notes. Raises: click.UsageError: No master passphrase or master SSH key was given on both the command-line and in the vault configuration on disk. """ service = self.ctx.params['service'] use_key = self.ctx.params['use_key'] use_phrase = self.ctx.params['use_phrase'] print_notes_before = self.ctx.params['print_notes_before'] user_config = self.get_user_config() settings = self.run_subop_query_phrase_or_key_change( empty_service_permitted=False ) if use_phrase: try: cli_helpers.check_for_misleading_passphrase( cli_helpers.ORIGIN.INTERACTIVE, {'phrase': settings['phrase']}, main_config=user_config, ctx=self.ctx, ) except AssertionError as exc: self.err( _msg.TranslatedString( _msg.ErrMsgTemplate.INVALID_USER_CONFIG, error=exc, filename=None, ).maybe_without_filename(), ) phrase: str | bytes overrides = cast('dict[str, int | str]', settings.maps[0]) # If either --key or --phrase are given, use that setting. # Otherwise, if both key and phrase are set in the config, # use the key. Otherwise, if only one of key and phrase is # set in the config, use that one. In all these above # cases, set the phrase via vault.Vault.phrase_from_key if # a key is given. Finally, if nothing is set, error out. if use_key: phrase = cli_helpers.key_to_phrase( cast('str', overrides['key']), error_callback=self.err ) elif use_phrase: phrase = cast('str', overrides['phrase']) elif settings.get('key'): phrase = cli_helpers.key_to_phrase( cast('str', settings['key']), error_callback=self.err ) elif settings.get('phrase'): phrase = cast('str', settings['phrase']) else: err_msg = _msg.TranslatedString( _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE ) raise click.UsageError(str(err_msg)) overrides.pop('key', '') overrides.pop('phrase', '') assert service is not None vault_service_keys = { 'length', 'repeat', 'lower', 'upper', 'number', 'space', 'dash', 'symbol', } kwargs = { cast('str', k): cast('int', settings[k]) for k in vault_service_keys if k in settings } result = vault.Vault(phrase=phrase, **kwargs).generate(service) service_notes = cast('str', settings.get('notes', '')).strip() if print_notes_before and service_notes.strip(): click.echo(f'{service_notes}\n', err=True, color=self.ctx.color) click.echo(result.decode('ASCII'), color=self.ctx.color) if not print_notes_before and service_notes.strip(): click.echo(f'\n{service_notes}\n', err=True, color=self.ctx.color) @derivepassphrase.command( 'vault', context_settings={'help_option_names': ['-h', '--help']}, cls=cli_machinery.CommandWithHelpGroups, help=( _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01), _msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_02, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ), ), epilog=( _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01), _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02), ), ) @click.option( '-p', '--phrase', 'use_phrase', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '-k', '--key', 'use_key', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '-l', '--length', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_length, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '-r', '--repeat', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--lower', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--upper', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--number', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--space', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--dash', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '--symbol', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), callback=cli_machinery.validate_occurrence_constraint, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.PassphraseGenerationOption, ) @click.option( '-n', '--notes', 'edit_notes', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ), cls=cli_machinery.ConfigurationOption, ) @click.option( '-c', '--config', 'store_config_only', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ), cls=cli_machinery.ConfigurationOption, ) @click.option( '-x', '--delete', 'delete_service_settings', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ), cls=cli_machinery.ConfigurationOption, ) @click.option( '--delete-globals', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT, ), cls=cli_machinery.ConfigurationOption, ) @click.option( '-X', '--clear', 'clear_all_settings', is_flag=True, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT, ), cls=cli_machinery.ConfigurationOption, ) @click.option( '-e', '--export', 'export_settings', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.StorageManagementOption, shell_complete=cli_helpers.shell_complete_path, ) @click.option( '-i', '--import', 'import_settings', metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT, metavar=_msg.TranslatedString( _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER ), ), cls=cli_machinery.StorageManagementOption, shell_complete=cli_helpers.shell_complete_path, ) @click.option( '--overwrite-existing/--merge-existing', 'overwrite_config', default=False, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT ), cls=cli_machinery.CompatibilityOption, ) @click.option( '--unset', 'unset_settings', multiple=True, type=click.Choice([ 'phrase', 'key', 'length', 'repeat', 'lower', 'upper', 'number', 'space', 'dash', 'symbol', 'notes', ]), help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT ), cls=cli_machinery.CompatibilityOption, ) @click.option( '--export-as', type=click.Choice(['json', 'sh']), default='json', help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT ), cls=cli_machinery.CompatibilityOption, ) @click.option( '--modern-editor-interface/--vault-legacy-editor-interface', 'modern_editor_interface', default=False, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_EDITOR_INTERFACE_HELP_TEXT ), cls=cli_machinery.CompatibilityOption, ) @click.option( '--print-notes-before/--print-notes-after', 'print_notes_before', default=False, help=_msg.TranslatedString( _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT, service_metavar=_msg.TranslatedString( _msg.Label.VAULT_METAVAR_SERVICE ), ), cls=cli_machinery.CompatibilityOption, ) @cli_machinery.version_option(cli_machinery.vault_version_option_callback) @cli_machinery.color_forcing_pseudo_option @cli_machinery.standard_logging_options @click.argument( 'service', metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE), required=False, default=None, shell_complete=cli_helpers.shell_complete_service, ) @click.pass_context def derivepassphrase_vault( ctx: click.Context, /, **_kwargs: Any, # noqa: ANN401 ) -> None: """Derive a passphrase using the vault(1) derivation scheme. This is a [`click`][CLICK]-powered command-line interface function, and not intended for programmatic use. See the derivepassphrase-vault(1) manpage for full documentation of the interface. (See also [`click.testing.CliRunner`][] for controlled, programmatic invocation.) [CLICK]: https://pypi.org/package/click/ Parameters: ctx (click.Context): The `click` context. Other Parameters: service (str | None): A service name. Required, unless operating on global settings or importing/exporting settings. use_phrase (bool): Command-line argument `-p`/`--phrase`. If given, query the user for a passphrase instead of an SSH key. use_key (bool): Command-line argument `-k`/`--key`. If given, query the user for an SSH key instead of a passphrase. length (int | None): Command-line argument `-l`/`--length`. Override the default length of the generated passphrase. repeat (int | None): Command-line argument `-r`/`--repeat`. Override the default repetition limit if positive, or disable the repetition limit if 0. lower (int | None): Command-line argument `--lower`. Require a given amount of ASCII lowercase characters if positive, else forbid ASCII lowercase characters if 0. upper (int | None): Command-line argument `--upper`. Same as `lower`, but for ASCII uppercase characters. number (int | None): Command-line argument `--number`. Same as `lower`, but for ASCII digits. space (int | None): Command-line argument `--space`. Same as `lower`, but for the space character. dash (int | None): Command-line argument `--dash`. Same as `lower`, but for the hyphen-minus and underscore characters. symbol (int | None): Command-line argument `--symbol`. Same as `lower`, but for all other ASCII printable characters except lowercase characters, uppercase characters, digits, space and backquote. edit_notes (bool): Command-line argument `-n`/`--notes`. If given, spawn an editor to edit notes for `service`. store_config_only (bool): Command-line argument `-c`/`--config`. If given, saves the other given settings (`--key`, ..., `--symbol`) to the configuration file, either specifically for `service` or as global settings. delete_service_settings (bool): Command-line argument `-x`/`--delete`. If given, removes the settings for `service` from the configuration file. delete_globals (bool): Command-line argument `--delete-globals`. If given, removes the global settings from the configuration file. clear_all_settings (bool): Command-line argument `-X`/`--clear`. If given, removes all settings from the configuration file. export_settings (TextIO | os.PathLike[str] | None): Command-line argument `-e`/`--export`. If a file object, then it must be open for writing and accept `str` inputs. Otherwise, a filename to open for writing. Using `-` for standard output is supported. import_settings (TextIO | os.PathLike[str] | None): Command-line argument `-i`/`--import`. If a file object, it must be open for reading and yield `str` values. Otherwise, a filename to open for reading. Using `-` for standard input is supported. overwrite_config (bool): Command-line arguments `--overwrite-existing` (True) and `--merge-existing` (False). Controls whether config saving and config importing overwrite existing configurations, or merge them section-wise instead. unset_settings (Sequence[str]): Command-line argument `--unset`. If given together with `--config`, unsets the specified settings (in addition to any other changes requested). export_as (Literal['json', 'sh']): Command-line argument `--export-as`. If given together with `--export`, selects the format to export the current configuration as: JSON ("json", default) or POSIX sh ("sh"). modern_editor_interface (bool): Command-line arguments `--modern-editor-interface` (True) and `--vault-legacy-editor-interface` (False). Controls whether editing notes uses a modern editor interface (supporting comments and aborting) or a vault(1)-compatible legacy editor interface (WYSIWYG notes contents). print_notes_before (bool): Command-line arguments `--print-notes-before` (True) and `--print-notes-after` (False). Controls whether the service notes (if any) are printed before the passphrase, or after. """ vault_context = _VaultContext(ctx) vault_context.validate_command_line() vault_context.dispatch_op() if __name__ == '__main__': derivepassphrase()