Marco Ricci commited on 2025-03-16 13:23:05
Zeige 3 geänderte Dateien mit 1449 Einfügungen und 1109 Löschungen.
Introduce a "vault context" object holding the associated data for the call to the `derivepassphrase vault` CLI, and implement the actual CLI as a collection of methods on this object. The "vault context" object mostly just wraps the click context object and encodes the dispatch table for the CLI operations. The main advantage of this modelling is the much smaller size per, and lower cyclomatic complexity of, each CLI operation. As an implementation detail, the old implementation of the `get_user_config` inner function's error handling branch for `OSError` had coverage testing purely by accident, because `get_user_config` was being called unconditionally, very early in the CLI control flow. The refactored CLI however reads the user configuration only as needed for the specific operation. So this error handling branch needs a new, separate test. As a further implementation detail, the `is_param_set` inner function no longer caches its result, because it is now an object method: `functools.cache` explicitly documents that adding a cache to object methods causes a circular reference between the cache and the object via the `self` parameter. Given the actual contents of this function, there appears to be little point in working around this memory issue just to outfit an already cheap operation (a single `Mapping.get` call) with a cache. As yet another further implementation detail, checking for misleading passphrases should work even if the passed configuration snippet is not technically a `dict`, but rather a `Mapping`. Finally, due to the vault context object now wrapping the click context, it is possible (and sensible) to wrap the logging calls into methods of the vault context object so that less out-of-bounds information needs to be passed to the logger explicitly at the call site.
... | ... |
@@ -45,6 +45,7 @@ if TYPE_CHECKING: |
45 | 45 |
import socket |
46 | 46 |
from collections.abc import ( |
47 | 47 |
Iterator, |
48 |
+ Mapping, |
|
48 | 49 |
Sequence, |
49 | 50 |
) |
50 | 51 |
|
... | ... |
@@ -714,7 +715,7 @@ class ORIGIN(enum.Enum): |
714 | 715 |
|
715 | 716 |
def check_for_misleading_passphrase( |
716 | 717 |
key: tuple[str, ...] | ORIGIN, |
717 |
- value: dict[str, Any], |
|
718 |
+ value: Mapping[str, Any], |
|
718 | 719 |
*, |
719 | 720 |
main_config: dict[str, Any], |
720 | 721 |
ctx: click.Context | None = None, |
... | ... |
@@ -11,12 +11,12 @@ from __future__ import annotations |
11 | 11 |
import base64 |
12 | 12 |
import collections |
13 | 13 |
import contextlib |
14 |
-import functools |
|
15 | 14 |
import json |
16 | 15 |
import logging |
17 | 16 |
import os |
18 | 17 |
from typing import ( |
19 | 18 |
TYPE_CHECKING, |
19 |
+ Final, |
|
20 | 20 |
Literal, |
21 | 21 |
NoReturn, |
22 | 22 |
TextIO, |
... | ... |
@@ -34,10 +34,8 @@ from derivepassphrase._internals import cli_helpers, cli_machinery |
34 | 34 |
from derivepassphrase._internals import cli_messages as _msg |
35 | 35 |
|
36 | 36 |
if TYPE_CHECKING: |
37 |
- from collections.abc import ( |
|
38 |
- Callable, |
|
39 |
- Sequence, |
|
40 |
- ) |
|
37 |
+ from collections.abc import Sequence |
|
38 |
+ from collections.abc import Set as AbstractSet |
|
41 | 39 |
|
42 | 40 |
__all__ = ('derivepassphrase',) |
43 | 41 |
|
... | ... |
@@ -317,263 +315,788 @@ def derivepassphrase_export_vault( |
317 | 315 |
ctx.exit(1) |
318 | 316 |
|
319 | 317 |
|
320 |
-@derivepassphrase.command( |
|
321 |
- 'vault', |
|
322 |
- context_settings={'help_option_names': ['-h', '--help']}, |
|
323 |
- cls=cli_machinery.CommandWithHelpGroups, |
|
324 |
- help=( |
|
325 |
- _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01), |
|
326 |
- _msg.TranslatedString( |
|
327 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_02, |
|
328 |
- service_metavar=_msg.TranslatedString( |
|
329 |
- _msg.Label.VAULT_METAVAR_SERVICE |
|
330 |
- ), |
|
331 |
- ), |
|
332 |
- ), |
|
333 |
- epilog=( |
|
334 |
- _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01), |
|
335 |
- _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02), |
|
336 |
- ), |
|
318 |
+class _VaultContext: # noqa: PLR0904 |
|
319 |
+ """The context for the "vault" command-line interface. |
|
320 |
+ |
|
321 |
+ This context object -- wrapping a [`click.Context`][] object -- |
|
322 |
+ encapsulates a single call to the `derivepassphrase vault` |
|
323 |
+ command-line. Although fully documented, this class is an |
|
324 |
+ implementation detail of the `derivepassphrase vault` command-line |
|
325 |
+ and should not be instantiated directly by users or API clients. |
|
326 |
+ |
|
327 |
+ Attributes: |
|
328 |
+ logger: |
|
329 |
+ The logger used for warnings and error messages. |
|
330 |
+ deprecation: |
|
331 |
+ The logger used for deprecation warnings. |
|
332 |
+ ctx: |
|
333 |
+ The underlying [`click.Context`][] from which the |
|
334 |
+ command-line settings and parameter values are queried. |
|
335 |
+ all_ops: |
|
336 |
+ A list of operations supported by the CLI, in the |
|
337 |
+ order that they are queried in the `click` context. The |
|
338 |
+ final entry is the default operation, and does not |
|
339 |
+ correspond to a `click` parameter; all others are `click` |
|
340 |
+ parameter names. |
|
341 |
+ readwrite_ops: |
|
342 |
+ A set of operations which modify the `derivepassphrase` |
|
343 |
+ state. All other operations are read-only. |
|
344 |
+ options_in_group: |
|
345 |
+ A mapping of option group names to lists of known options |
|
346 |
+ from this group. Used during the validation of the command |
|
347 |
+ line. |
|
348 |
+ params_by_str: |
|
349 |
+ A mapping of option names (long names, short names, etc.) to |
|
350 |
+ option objects. Used during the validation of the command |
|
351 |
+ line. |
|
352 |
+ |
|
353 |
+ """ |
|
354 |
+ |
|
355 |
+ logger: Final = logging.getLogger(PROG_NAME) |
|
356 |
+ """""" |
|
357 |
+ deprecation: Final = logging.getLogger(PROG_NAME + '.deprecation') |
|
358 |
+ """""" |
|
359 |
+ ctx: Final[click.Context] |
|
360 |
+ """""" |
|
361 |
+ all_ops: tuple[str, ...] = ( |
|
362 |
+ 'delete_service_settings', |
|
363 |
+ 'delete_globals', |
|
364 |
+ 'clear_all_settings', |
|
365 |
+ 'import_settings', |
|
366 |
+ 'export_settings', |
|
367 |
+ 'store_config_only', |
|
368 |
+ # The default op "derive_passphrase" must be last! |
|
369 |
+ 'derive_passphrase', |
|
337 | 370 |
) |
338 |
-@click.option( |
|
339 |
- '-p', |
|
340 |
- '--phrase', |
|
341 |
- 'use_phrase', |
|
342 |
- is_flag=True, |
|
343 |
- help=_msg.TranslatedString( |
|
344 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT |
|
345 |
- ), |
|
346 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
371 |
+ readwrite_ops: AbstractSet[str] = frozenset({ |
|
372 |
+ 'delete_service_settings', |
|
373 |
+ 'delete_globals', |
|
374 |
+ 'clear_all_settings', |
|
375 |
+ 'import_settings', |
|
376 |
+ 'store_config_only', |
|
377 |
+ }) |
|
378 |
+ options_in_group: dict[type[click.Option], list[click.Option]] |
|
379 |
+ params_by_str: dict[str, click.Parameter] |
|
380 |
+ |
|
381 |
+ def __init__(self, ctx: click.Context, /) -> None: |
|
382 |
+ """Initialize the vault context. |
|
383 |
+ |
|
384 |
+ Args: |
|
385 |
+ ctx: |
|
386 |
+ The underlying [`click.Context`][] from which the |
|
387 |
+ command-line settings and parameter values are queried. |
|
388 |
+ |
|
389 |
+ """ |
|
390 |
+ self.ctx = ctx |
|
391 |
+ self.params_by_str = {} |
|
392 |
+ self.options_in_group = {} |
|
393 |
+ for param in ctx.command.params: |
|
394 |
+ if isinstance(param, click.Option): |
|
395 |
+ group: type[click.Option] |
|
396 |
+ known_option_groups = [ |
|
397 |
+ cli_machinery.PassphraseGenerationOption, |
|
398 |
+ cli_machinery.ConfigurationOption, |
|
399 |
+ cli_machinery.StorageManagementOption, |
|
400 |
+ cli_machinery.LoggingOption, |
|
401 |
+ cli_machinery.CompatibilityOption, |
|
402 |
+ cli_machinery.StandardOption, |
|
403 |
+ ] |
|
404 |
+ if isinstance(param, cli_machinery.OptionGroupOption): |
|
405 |
+ for class_ in known_option_groups: |
|
406 |
+ if isinstance(param, class_): |
|
407 |
+ group = class_ |
|
408 |
+ break |
|
409 |
+ else: # pragma: no cover |
|
410 |
+ assert False, f'Unknown option group for {param!r}' # noqa: B011,PT015 |
|
411 |
+ else: |
|
412 |
+ group = click.Option |
|
413 |
+ self.options_in_group.setdefault(group, []).append(param) |
|
414 |
+ self.params_by_str[param.human_readable_name] = param |
|
415 |
+ for name in param.opts + param.secondary_opts: |
|
416 |
+ self.params_by_str[name] = param |
|
417 |
+ |
|
418 |
+ def is_param_set(self, param: click.Parameter, /) -> bool: |
|
419 |
+ """Return true if the parameter is set.""" |
|
420 |
+ return bool(self.ctx.params.get(param.human_readable_name)) |
|
421 |
+ |
|
422 |
+ def option_name(self, param: click.Parameter | str, /) -> str: |
|
423 |
+ """Return the option name of a parameter. |
|
424 |
+ |
|
425 |
+ Annoyingly, `param.human_readable_name` contains the *function* |
|
426 |
+ parameter name, not the list of option names. *Those* are |
|
427 |
+ stashed in the `.opts` and `.secondary_opts` attributes, which |
|
428 |
+ are visible in the `.to_info_dict()` output, but not otherwise |
|
429 |
+ documented. We return the shortest one among the long-form |
|
430 |
+ option names. |
|
431 |
+ |
|
432 |
+ Args: |
|
433 |
+ param: The parameter whose option name is requested. |
|
434 |
+ |
|
435 |
+ Raises: |
|
436 |
+ ValueError: The parameter has no long-form option names. |
|
437 |
+ |
|
438 |
+ """ |
|
439 |
+ param = self.params_by_str[param] if isinstance(param, str) else param |
|
440 |
+ names = [param.human_readable_name, *param.opts, *param.secondary_opts] |
|
441 |
+ option_names = [n for n in names if n.startswith('--')] |
|
442 |
+ return min(option_names, key=len) |
|
443 |
+ |
|
444 |
+ def check_incompatible_options( |
|
445 |
+ self, |
|
446 |
+ param1: click.Parameter | str, |
|
447 |
+ param2: click.Parameter | str, |
|
448 |
+ ) -> None: |
|
449 |
+ """Raise an error if the two options are incompatible. |
|
450 |
+ |
|
451 |
+ Raises: |
|
452 |
+ click.BadOptionUsage: The given options are incompatible. |
|
453 |
+ |
|
454 |
+ """ |
|
455 |
+ param1 = ( |
|
456 |
+ self.params_by_str[param1] if isinstance(param1, str) else param1 |
|
347 | 457 |
) |
348 |
-@click.option( |
|
349 |
- '-k', |
|
350 |
- '--key', |
|
351 |
- 'use_key', |
|
352 |
- is_flag=True, |
|
353 |
- help=_msg.TranslatedString( |
|
354 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT |
|
355 |
- ), |
|
356 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
458 |
+ param2 = ( |
|
459 |
+ self.params_by_str[param2] if isinstance(param2, str) else param2 |
|
357 | 460 |
) |
358 |
-@click.option( |
|
359 |
- '-l', |
|
360 |
- '--length', |
|
361 |
- metavar=_msg.TranslatedString( |
|
362 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
363 |
- ), |
|
364 |
- callback=cli_machinery.validate_length, |
|
365 |
- help=_msg.TranslatedString( |
|
366 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT, |
|
367 |
- metavar=_msg.TranslatedString( |
|
368 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
369 |
- ), |
|
370 |
- ), |
|
371 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
461 |
+ if param1 == param2: |
|
462 |
+ return |
|
463 |
+ if not self.is_param_set(param1): |
|
464 |
+ return |
|
465 |
+ if self.is_param_set(param2): |
|
466 |
+ param1_str = self.option_name(param1) |
|
467 |
+ param2_str = self.option_name(param2) |
|
468 |
+ raise click.BadOptionUsage( |
|
469 |
+ param1_str, |
|
470 |
+ str( |
|
471 |
+ _msg.TranslatedString( |
|
472 |
+ _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE, |
|
473 |
+ param1=param1_str, |
|
474 |
+ param2=param2_str, |
|
372 | 475 |
) |
373 |
-@click.option( |
|
374 |
- '-r', |
|
375 |
- '--repeat', |
|
376 |
- metavar=_msg.TranslatedString( |
|
377 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
378 |
- ), |
|
379 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
380 |
- help=_msg.TranslatedString( |
|
381 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT, |
|
382 |
- metavar=_msg.TranslatedString( |
|
383 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
384 |
- ), |
|
385 | 476 |
), |
386 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
477 |
+ ctx=self.ctx, |
|
387 | 478 |
) |
388 |
-@click.option( |
|
389 |
- '--lower', |
|
390 |
- metavar=_msg.TranslatedString( |
|
391 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
392 |
- ), |
|
393 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
394 |
- help=_msg.TranslatedString( |
|
395 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT, |
|
396 |
- metavar=_msg.TranslatedString( |
|
397 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
398 |
- ), |
|
399 |
- ), |
|
400 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
479 |
+ return |
|
480 |
+ |
|
481 |
+ def err(self, msg: Any, /, **kwargs: Any) -> NoReturn: # noqa: ANN401 |
|
482 |
+ """Log an error, then abort the function call. |
|
483 |
+ |
|
484 |
+ We ensure that color handling is done properly before the error |
|
485 |
+ is logged. |
|
486 |
+ |
|
487 |
+ """ |
|
488 |
+ stacklevel = kwargs.pop('stacklevel', 1) |
|
489 |
+ stacklevel += 1 |
|
490 |
+ extra = kwargs.pop('extra', {}) |
|
491 |
+ extra.setdefault('color', self.ctx.color) |
|
492 |
+ self.logger.error(msg, stacklevel=stacklevel, extra=extra, **kwargs) |
|
493 |
+ self.ctx.exit(1) |
|
494 |
+ |
|
495 |
+ def warning(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 |
|
496 |
+ """Log a warning. |
|
497 |
+ |
|
498 |
+ We ensure that color handling is done properly before the |
|
499 |
+ warning is logged. |
|
500 |
+ |
|
501 |
+ """ |
|
502 |
+ stacklevel = kwargs.pop('stacklevel', 1) |
|
503 |
+ stacklevel += 1 |
|
504 |
+ extra = kwargs.pop('extra', {}) |
|
505 |
+ extra.setdefault('color', self.ctx.color) |
|
506 |
+ self.logger.warning(msg, stacklevel=stacklevel, extra=extra, **kwargs) |
|
507 |
+ |
|
508 |
+ def deprecation_warning(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 |
|
509 |
+ """Log a deprecation warning. |
|
510 |
+ |
|
511 |
+ We ensure that color handling is done properly before the |
|
512 |
+ warning is logged. |
|
513 |
+ |
|
514 |
+ """ |
|
515 |
+ stacklevel = kwargs.pop('stacklevel', 1) |
|
516 |
+ stacklevel += 1 |
|
517 |
+ extra = kwargs.pop('extra', {}) |
|
518 |
+ extra.setdefault('color', self.ctx.color) |
|
519 |
+ self.deprecation.warning( |
|
520 |
+ msg, stacklevel=stacklevel, extra=extra, **kwargs |
|
401 | 521 |
) |
402 |
-@click.option( |
|
403 |
- '--upper', |
|
404 |
- metavar=_msg.TranslatedString( |
|
405 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
406 |
- ), |
|
407 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
408 |
- help=_msg.TranslatedString( |
|
409 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT, |
|
410 |
- metavar=_msg.TranslatedString( |
|
411 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
412 |
- ), |
|
413 |
- ), |
|
414 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
522 |
+ |
|
523 |
+ def deprecation_info(self, msg: Any, /, **kwargs: Any) -> None: # noqa: ANN401 |
|
524 |
+ """Log a deprecation info message. |
|
525 |
+ |
|
526 |
+ We ensure that color handling is done properly before the |
|
527 |
+ warning is logged. |
|
528 |
+ |
|
529 |
+ """ |
|
530 |
+ stacklevel = kwargs.pop('stacklevel', 1) |
|
531 |
+ stacklevel += 1 |
|
532 |
+ extra = kwargs.pop('extra', {}) |
|
533 |
+ extra.setdefault('color', self.ctx.color) |
|
534 |
+ self.deprecation.info( |
|
535 |
+ msg, stacklevel=stacklevel, extra=extra, **kwargs |
|
415 | 536 |
) |
416 |
-@click.option( |
|
417 |
- '--number', |
|
418 |
- metavar=_msg.TranslatedString( |
|
419 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
420 |
- ), |
|
421 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
422 |
- help=_msg.TranslatedString( |
|
423 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT, |
|
424 |
- metavar=_msg.TranslatedString( |
|
425 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
426 |
- ), |
|
537 |
+ |
|
538 |
+ def get_config(self) -> _types.VaultConfig: |
|
539 |
+ """Return the vault configuration stored on disk. |
|
540 |
+ |
|
541 |
+ If no configuration is stored, return an empty configuration. |
|
542 |
+ |
|
543 |
+ If only a v0.1-style configuration is found, attempt to migrate |
|
544 |
+ it. This will be removed in v1.0. |
|
545 |
+ |
|
546 |
+ """ |
|
547 |
+ try: |
|
548 |
+ return cli_helpers.load_config() |
|
549 |
+ except FileNotFoundError: |
|
550 |
+ # TODO(the-13th-letter): Return the empty default |
|
551 |
+ # configuration directly. |
|
552 |
+ # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file |
|
553 |
+ try: |
|
554 |
+ backup_config, exc = cli_helpers.migrate_and_load_old_config() |
|
555 |
+ except FileNotFoundError: |
|
556 |
+ return {'services': {}} |
|
557 |
+ old_name = cli_helpers.config_filename( |
|
558 |
+ subsystem='old settings.json' |
|
559 |
+ ).name |
|
560 |
+ new_name = cli_helpers.config_filename(subsystem='vault').name |
|
561 |
+ self.deprecation_warning( |
|
562 |
+ _msg.TranslatedString( |
|
563 |
+ _msg.WarnMsgTemplate.V01_STYLE_CONFIG, |
|
564 |
+ old=old_name, |
|
565 |
+ new=new_name, |
|
427 | 566 |
), |
428 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
429 | 567 |
) |
430 |
-@click.option( |
|
431 |
- '--space', |
|
432 |
- metavar=_msg.TranslatedString( |
|
433 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
434 |
- ), |
|
435 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
436 |
- help=_msg.TranslatedString( |
|
437 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT, |
|
438 |
- metavar=_msg.TranslatedString( |
|
439 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
440 |
- ), |
|
441 |
- ), |
|
442 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
568 |
+ if isinstance(exc, OSError): |
|
569 |
+ self.warning( |
|
570 |
+ _msg.TranslatedString( |
|
571 |
+ _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG, |
|
572 |
+ path=new_name, |
|
573 |
+ error=exc.strerror, |
|
574 |
+ filename=exc.filename, |
|
575 |
+ ).maybe_without_filename(), |
|
443 | 576 |
) |
444 |
-@click.option( |
|
445 |
- '--dash', |
|
446 |
- metavar=_msg.TranslatedString( |
|
447 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
448 |
- ), |
|
449 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
450 |
- help=_msg.TranslatedString( |
|
451 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT, |
|
452 |
- metavar=_msg.TranslatedString( |
|
453 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
454 |
- ), |
|
577 |
+ else: |
|
578 |
+ self.deprecation_info( |
|
579 |
+ _msg.TranslatedString( |
|
580 |
+ _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED, |
|
581 |
+ path=new_name, |
|
455 | 582 |
), |
456 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
457 | 583 |
) |
458 |
-@click.option( |
|
459 |
- '--symbol', |
|
460 |
- metavar=_msg.TranslatedString( |
|
461 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
462 |
- ), |
|
463 |
- callback=cli_machinery.validate_occurrence_constraint, |
|
464 |
- help=_msg.TranslatedString( |
|
465 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT, |
|
466 |
- metavar=_msg.TranslatedString( |
|
467 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
468 |
- ), |
|
469 |
- ), |
|
470 |
- cls=cli_machinery.PassphraseGenerationOption, |
|
584 |
+ return backup_config |
|
585 |
+ except OSError as exc: |
|
586 |
+ self.err( |
|
587 |
+ _msg.TranslatedString( |
|
588 |
+ _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, |
|
589 |
+ error=exc.strerror, |
|
590 |
+ filename=exc.filename, |
|
591 |
+ ).maybe_without_filename(), |
|
471 | 592 |
) |
472 |
-@click.option( |
|
473 |
- '-n', |
|
474 |
- '--notes', |
|
475 |
- 'edit_notes', |
|
476 |
- is_flag=True, |
|
477 |
- help=_msg.TranslatedString( |
|
478 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT, |
|
593 |
+ except Exception as exc: # noqa: BLE001 |
|
594 |
+ self.err( |
|
595 |
+ _msg.TranslatedString( |
|
596 |
+ _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, |
|
597 |
+ error=str(exc), |
|
598 |
+ filename=None, |
|
599 |
+ ).maybe_without_filename(), |
|
600 |
+ exc_info=exc, |
|
601 |
+ ) |
|
602 |
+ |
|
603 |
+ def put_config(self, config: _types.VaultConfig, /) -> None: |
|
604 |
+ """Store the given vault configuration to disk.""" |
|
605 |
+ try: |
|
606 |
+ cli_helpers.save_config(config) |
|
607 |
+ except OSError as exc: |
|
608 |
+ self.err( |
|
609 |
+ _msg.TranslatedString( |
|
610 |
+ _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, |
|
611 |
+ error=exc.strerror, |
|
612 |
+ filename=exc.filename, |
|
613 |
+ ).maybe_without_filename(), |
|
614 |
+ ) |
|
615 |
+ except Exception as exc: # noqa: BLE001 |
|
616 |
+ self.err( |
|
617 |
+ _msg.TranslatedString( |
|
618 |
+ _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, |
|
619 |
+ error=str(exc), |
|
620 |
+ filename=None, |
|
621 |
+ ).maybe_without_filename(), |
|
622 |
+ exc_info=exc, |
|
623 |
+ ) |
|
624 |
+ |
|
625 |
+ def get_user_config(self) -> dict[str, Any]: |
|
626 |
+ """Return the global user configuration stored on disk. |
|
627 |
+ |
|
628 |
+ If no configuration is stored, return an empty configuration. |
|
629 |
+ |
|
630 |
+ """ |
|
631 |
+ try: |
|
632 |
+ return cli_helpers.load_user_config() |
|
633 |
+ except FileNotFoundError: |
|
634 |
+ return {} |
|
635 |
+ except OSError as exc: |
|
636 |
+ self.err( |
|
637 |
+ _msg.TranslatedString( |
|
638 |
+ _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, |
|
639 |
+ error=exc.strerror, |
|
640 |
+ filename=exc.filename, |
|
641 |
+ ).maybe_without_filename(), |
|
642 |
+ ) |
|
643 |
+ except Exception as exc: # noqa: BLE001 |
|
644 |
+ self.err( |
|
645 |
+ _msg.TranslatedString( |
|
646 |
+ _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, |
|
647 |
+ error=str(exc), |
|
648 |
+ filename=None, |
|
649 |
+ ).maybe_without_filename(), |
|
650 |
+ exc_info=exc, |
|
651 |
+ ) |
|
652 |
+ |
|
653 |
+ def validate_command_line(self) -> None: # noqa: C901,PLR0912 |
|
654 |
+ """Check for missing/extra arguments and conflicting options. |
|
655 |
+ |
|
656 |
+ Raises: |
|
657 |
+ click.UsageError: |
|
658 |
+ The command-line is invalid because of missing or extra |
|
659 |
+ arguments, or because of conflicting options. The error |
|
660 |
+ message contains further details. |
|
661 |
+ |
|
662 |
+ """ |
|
663 |
+ param: click.Parameter |
|
664 |
+ self.check_incompatible_options('--phrase', '--key') |
|
665 |
+ for group in ( |
|
666 |
+ cli_machinery.ConfigurationOption, |
|
667 |
+ cli_machinery.StorageManagementOption, |
|
668 |
+ ): |
|
669 |
+ for opt in self.options_in_group[group]: |
|
670 |
+ if opt not in { |
|
671 |
+ self.params_by_str['--config'], |
|
672 |
+ self.params_by_str['--notes'], |
|
673 |
+ }: |
|
674 |
+ for other_opt in self.options_in_group[ |
|
675 |
+ cli_machinery.PassphraseGenerationOption |
|
676 |
+ ]: |
|
677 |
+ self.check_incompatible_options(opt, other_opt) |
|
678 |
+ |
|
679 |
+ for group in ( |
|
680 |
+ cli_machinery.ConfigurationOption, |
|
681 |
+ cli_machinery.StorageManagementOption, |
|
682 |
+ ): |
|
683 |
+ for opt in self.options_in_group[group]: |
|
684 |
+ for other_opt in self.options_in_group[ |
|
685 |
+ cli_machinery.ConfigurationOption |
|
686 |
+ ]: |
|
687 |
+ if {opt, other_opt} != { |
|
688 |
+ self.params_by_str['--config'], |
|
689 |
+ self.params_by_str['--notes'], |
|
690 |
+ }: |
|
691 |
+ self.check_incompatible_options(opt, other_opt) |
|
692 |
+ for other_opt in self.options_in_group[ |
|
693 |
+ cli_machinery.StorageManagementOption |
|
694 |
+ ]: |
|
695 |
+ self.check_incompatible_options(opt, other_opt) |
|
696 |
+ service: str | None = self.ctx.params['service'] |
|
479 | 697 |
service_metavar = _msg.TranslatedString( |
480 | 698 |
_msg.Label.VAULT_METAVAR_SERVICE |
481 |
- ), |
|
482 |
- ), |
|
483 |
- cls=cli_machinery.ConfigurationOption, |
|
484 | 699 |
) |
485 |
-@click.option( |
|
486 |
- '-c', |
|
487 |
- '--config', |
|
488 |
- 'store_config_only', |
|
489 |
- is_flag=True, |
|
490 |
- help=_msg.TranslatedString( |
|
491 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT, |
|
700 |
+ sv_or_global_options = self.options_in_group[ |
|
701 |
+ cli_machinery.PassphraseGenerationOption |
|
702 |
+ ] |
|
703 |
+ for param in sv_or_global_options: |
|
704 |
+ if self.is_param_set(param) and not ( |
|
705 |
+ service is not None |
|
706 |
+ or self.is_param_set(self.params_by_str['--config']) |
|
707 |
+ ): |
|
708 |
+ err_msg = _msg.TranslatedString( |
|
709 |
+ _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG, |
|
710 |
+ param=param.opts[0], |
|
711 |
+ service_metavar=service_metavar, |
|
712 |
+ ) |
|
713 |
+ raise click.UsageError(str(err_msg)) |
|
714 |
+ sv_options = [ |
|
715 |
+ self.params_by_str['--notes'], |
|
716 |
+ self.params_by_str['--delete'], |
|
717 |
+ ] |
|
718 |
+ for param in sv_options: |
|
719 |
+ if self.is_param_set(param) and not service is not None: |
|
720 |
+ err_msg = _msg.TranslatedString( |
|
721 |
+ _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE, |
|
722 |
+ param=param.opts[0], |
|
723 |
+ service_metavar=service_metavar, |
|
724 |
+ ) |
|
725 |
+ raise click.UsageError(str(err_msg)) |
|
726 |
+ no_sv_options = [ |
|
727 |
+ self.params_by_str['--delete-globals'], |
|
728 |
+ self.params_by_str['--clear'], |
|
729 |
+ *self.options_in_group[cli_machinery.StorageManagementOption], |
|
730 |
+ ] |
|
731 |
+ for param in no_sv_options: |
|
732 |
+ if self.is_param_set(param) and service is not None: |
|
733 |
+ err_msg = _msg.TranslatedString( |
|
734 |
+ _msg.ErrMsgTemplate.PARAMS_NO_SERVICE, |
|
735 |
+ param=param.opts[0], |
|
736 |
+ service_metavar=service_metavar, |
|
737 |
+ ) |
|
738 |
+ raise click.UsageError(str(err_msg)) |
|
739 |
+ |
|
740 |
+ def get_mutex(self, op: str) -> contextlib.AbstractContextManager[None]: |
|
741 |
+ """Return a mutex for accessing the configuration on disk. |
|
742 |
+ |
|
743 |
+ The mutex is a context manager, and will lock out other threads |
|
744 |
+ and processes attempting to access the configuration in an |
|
745 |
+ incompatible manner. |
|
746 |
+ |
|
747 |
+ Returns: |
|
748 |
+ If the requested operation is a read-only operation, return |
|
749 |
+ a no-op mutex. (Concurrent reads are always allowed, even |
|
750 |
+ in the presence of writers.) Otherwise, for read-write |
|
751 |
+ operations, returns the result from |
|
752 |
+ [`cli_helpers.configuration_mutex`][]. |
|
753 |
+ |
|
754 |
+ """ |
|
755 |
+ return ( |
|
756 |
+ cli_helpers.configuration_mutex() |
|
757 |
+ if op in self.readwrite_ops |
|
758 |
+ else contextlib.nullcontext() |
|
759 |
+ ) |
|
760 |
+ |
|
761 |
+ def dispatch_op(self) -> None: |
|
762 |
+ """Dispatch to the handler matching the command-line call. |
|
763 |
+ |
|
764 |
+ Also issue any appropriate warnings about the command-line, |
|
765 |
+ e.g., incompatibilities with vault(1) or ineffective options. |
|
766 |
+ |
|
767 |
+ """ |
|
492 | 768 |
service_metavar = _msg.TranslatedString( |
493 | 769 |
_msg.Label.VAULT_METAVAR_SERVICE |
770 |
+ ) |
|
771 |
+ if self.ctx.params['service'] == '': # noqa: PLC1901 |
|
772 |
+ self.warning( |
|
773 |
+ _msg.TranslatedString( |
|
774 |
+ _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED, |
|
775 |
+ service_metavar=service_metavar, |
|
494 | 776 |
), |
777 |
+ ) |
|
778 |
+ if self.ctx.params.get('edit_notes') and not self.ctx.params.get( |
|
779 |
+ 'store_config_only' |
|
780 |
+ ): |
|
781 |
+ self.warning( |
|
782 |
+ _msg.TranslatedString( |
|
783 |
+ _msg.WarnMsgTemplate.EDITING_NOTES_BUT_NOT_STORING_CONFIG, |
|
784 |
+ service_metavar=service_metavar, |
|
495 | 785 |
), |
496 |
- cls=cli_machinery.ConfigurationOption, |
|
497 | 786 |
) |
498 |
-@click.option( |
|
499 |
- '-x', |
|
500 |
- '--delete', |
|
501 |
- 'delete_service_settings', |
|
502 |
- is_flag=True, |
|
503 |
- help=_msg.TranslatedString( |
|
504 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT, |
|
787 |
+ op: str |
|
788 |
+ for candidate_op in self.all_ops[:-1]: |
|
789 |
+ if self.ctx.params.get(candidate_op): |
|
790 |
+ op = candidate_op |
|
791 |
+ break |
|
792 |
+ else: |
|
793 |
+ op = self.all_ops[-1] |
|
794 |
+ with self.get_mutex(op): |
|
795 |
+ op_func = getattr(self, 'run_op_' + op) |
|
796 |
+ return op_func() |
|
797 |
+ |
|
798 |
+ def run_op_delete_service_settings(self) -> None: |
|
799 |
+ """Delete settings for a specific service.""" |
|
800 |
+ service = self.ctx.params['service'] |
|
801 |
+ assert service is not None |
|
802 |
+ configuration = self.get_config() |
|
803 |
+ if service in configuration['services']: |
|
804 |
+ del configuration['services'][service] |
|
805 |
+ self.put_config(configuration) |
|
806 |
+ |
|
807 |
+ def run_op_delete_globals(self) -> None: |
|
808 |
+ """Delete the global settings.""" |
|
809 |
+ configuration = self.get_config() |
|
810 |
+ if 'global' in configuration: |
|
811 |
+ del configuration['global'] |
|
812 |
+ self.put_config(configuration) |
|
813 |
+ |
|
814 |
+ def run_op_clear_all_settings(self) -> None: |
|
815 |
+ """Clear all settings.""" |
|
816 |
+ self.put_config({'services': {}}) |
|
817 |
+ |
|
818 |
+ def run_op_import_settings(self) -> None: # noqa: C901,PLR0912 |
|
819 |
+ """Import settings from a given file. |
|
820 |
+ |
|
821 |
+ Issue multiple warnings, if appropriate, e.g. for Unicode |
|
822 |
+ normalization issues with stored passphrases or conflicting |
|
823 |
+ stored passphrases and keys. Respect the `--overwrite-config` |
|
824 |
+ and `--merge-config` options when writing the imported |
|
825 |
+ configuration to disk. |
|
826 |
+ |
|
827 |
+ """ |
|
505 | 828 |
service_metavar = _msg.TranslatedString( |
506 | 829 |
_msg.Label.VAULT_METAVAR_SERVICE |
830 |
+ ) |
|
831 |
+ user_config = self.get_user_config() |
|
832 |
+ import_settings = self.ctx.params['import_settings'] |
|
833 |
+ overwrite_config = self.ctx.params['overwrite_config'] |
|
834 |
+ try: |
|
835 |
+ # TODO(the-13th-letter): keep track of auto-close; try |
|
836 |
+ # os.dup if feasible |
|
837 |
+ infile = cast( |
|
838 |
+ 'TextIO', |
|
839 |
+ ( |
|
840 |
+ import_settings |
|
841 |
+ if hasattr(import_settings, 'close') |
|
842 |
+ else click.open_file(os.fspath(import_settings), 'rt') |
|
507 | 843 |
), |
844 |
+ ) |
|
845 |
+ # Don't specifically catch TypeError or ValueError here if |
|
846 |
+ # the passed-in fileobj is not a readable text stream. This |
|
847 |
+ # will never happen on the command-line (thanks to `click`), |
|
848 |
+ # and for programmatic use, our caller may want accurate |
|
849 |
+ # error information. |
|
850 |
+ with infile: |
|
851 |
+ maybe_config = json.load(infile) |
|
852 |
+ except json.JSONDecodeError as exc: |
|
853 |
+ self.err( |
|
854 |
+ _msg.TranslatedString( |
|
855 |
+ _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS, |
|
856 |
+ error=exc, |
|
857 |
+ ) |
|
858 |
+ ) |
|
859 |
+ except OSError as exc: |
|
860 |
+ self.err( |
|
861 |
+ _msg.TranslatedString( |
|
862 |
+ _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, |
|
863 |
+ error=exc.strerror, |
|
864 |
+ filename=exc.filename, |
|
865 |
+ ).maybe_without_filename() |
|
866 |
+ ) |
|
867 |
+ cleaned = _types.clean_up_falsy_vault_config_values(maybe_config) |
|
868 |
+ if not _types.is_vault_config(maybe_config): |
|
869 |
+ self.err( |
|
870 |
+ _msg.TranslatedString( |
|
871 |
+ _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, |
|
872 |
+ error=_msg.TranslatedString( |
|
873 |
+ _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG, |
|
874 |
+ config=maybe_config, |
|
508 | 875 |
), |
509 |
- cls=cli_machinery.ConfigurationOption, |
|
876 |
+ filename=None, |
|
877 |
+ ).maybe_without_filename() |
|
510 | 878 |
) |
511 |
-@click.option( |
|
512 |
- '--delete-globals', |
|
513 |
- is_flag=True, |
|
514 |
- help=_msg.TranslatedString( |
|
515 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT, |
|
879 |
+ assert cleaned is not None |
|
880 |
+ for step in cleaned: |
|
881 |
+ # These are never fatal errors, because the semantics of |
|
882 |
+ # vault upon encountering these settings are ill-specified, |
|
883 |
+ # but not ill-defined. |
|
884 |
+ if step.action == 'replace': |
|
885 |
+ self.warning( |
|
886 |
+ _msg.TranslatedString( |
|
887 |
+ _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE, |
|
888 |
+ old=json.dumps(step.old_value), |
|
889 |
+ path=_types.json_path(step.path), |
|
890 |
+ new=json.dumps(step.new_value), |
|
516 | 891 |
), |
517 |
- cls=cli_machinery.ConfigurationOption, |
|
518 | 892 |
) |
519 |
-@click.option( |
|
520 |
- '-X', |
|
521 |
- '--clear', |
|
522 |
- 'clear_all_settings', |
|
523 |
- is_flag=True, |
|
524 |
- help=_msg.TranslatedString( |
|
525 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT, |
|
893 |
+ else: |
|
894 |
+ self.warning( |
|
895 |
+ _msg.TranslatedString( |
|
896 |
+ _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE, |
|
897 |
+ path=_types.json_path(step.path), |
|
898 |
+ old=json.dumps(step.old_value), |
|
526 | 899 |
), |
527 |
- cls=cli_machinery.ConfigurationOption, |
|
528 | 900 |
) |
529 |
-@click.option( |
|
530 |
- '-e', |
|
531 |
- '--export', |
|
532 |
- 'export_settings', |
|
533 |
- metavar=_msg.TranslatedString( |
|
534 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
535 |
- ), |
|
536 |
- help=_msg.TranslatedString( |
|
537 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT, |
|
538 |
- metavar=_msg.TranslatedString( |
|
539 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
901 |
+ if '' in maybe_config['services']: |
|
902 |
+ self.warning( |
|
903 |
+ _msg.TranslatedString( |
|
904 |
+ _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE, |
|
905 |
+ service_metavar=service_metavar, |
|
906 |
+ PROG_NAME=PROG_NAME, |
|
540 | 907 |
), |
908 |
+ ) |
|
909 |
+ for service_name in sorted(maybe_config['services'].keys()): |
|
910 |
+ if not cli_helpers.is_completable_item(service_name): |
|
911 |
+ self.warning( |
|
912 |
+ _msg.TranslatedString( |
|
913 |
+ _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
|
914 |
+ service=service_name, |
|
541 | 915 |
), |
542 |
- cls=cli_machinery.StorageManagementOption, |
|
543 |
- shell_complete=cli_helpers.shell_complete_path, |
|
544 | 916 |
) |
545 |
-@click.option( |
|
546 |
- '-i', |
|
547 |
- '--import', |
|
548 |
- 'import_settings', |
|
549 |
- metavar=_msg.TranslatedString( |
|
550 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
917 |
+ try: |
|
918 |
+ cli_helpers.check_for_misleading_passphrase( |
|
919 |
+ ('global',), |
|
920 |
+ cast('dict[str, Any]', maybe_config.get('global', {})), |
|
921 |
+ main_config=user_config, |
|
922 |
+ ctx=self.ctx, |
|
923 |
+ ) |
|
924 |
+ for key, value in maybe_config['services'].items(): |
|
925 |
+ cli_helpers.check_for_misleading_passphrase( |
|
926 |
+ ('services', key), |
|
927 |
+ cast('dict[str, Any]', value), |
|
928 |
+ main_config=user_config, |
|
929 |
+ ctx=self.ctx, |
|
930 |
+ ) |
|
931 |
+ except AssertionError as exc: |
|
932 |
+ self.err( |
|
933 |
+ _msg.TranslatedString( |
|
934 |
+ _msg.ErrMsgTemplate.INVALID_USER_CONFIG, |
|
935 |
+ error=exc, |
|
936 |
+ filename=None, |
|
937 |
+ ).maybe_without_filename(), |
|
938 |
+ ) |
|
939 |
+ global_obj = maybe_config.get('global', {}) |
|
940 |
+ has_key = _types.js_truthiness(global_obj.get('key')) |
|
941 |
+ has_phrase = _types.js_truthiness(global_obj.get('phrase')) |
|
942 |
+ if has_key and has_phrase: |
|
943 |
+ self.warning( |
|
944 |
+ _msg.TranslatedString( |
|
945 |
+ _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE, |
|
551 | 946 |
), |
552 |
- help=_msg.TranslatedString( |
|
553 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT, |
|
554 |
- metavar=_msg.TranslatedString( |
|
555 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
947 |
+ ) |
|
948 |
+ for service_name, service_obj in maybe_config['services'].items(): |
|
949 |
+ has_key = _types.js_truthiness( |
|
950 |
+ service_obj.get('key') |
|
951 |
+ ) or _types.js_truthiness(global_obj.get('key')) |
|
952 |
+ has_phrase = _types.js_truthiness( |
|
953 |
+ service_obj.get('phrase') |
|
954 |
+ ) or _types.js_truthiness(global_obj.get('phrase')) |
|
955 |
+ if has_key and has_phrase: |
|
956 |
+ self.warning( |
|
957 |
+ _msg.TranslatedString( |
|
958 |
+ _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, |
|
959 |
+ service=json.dumps(service_name), |
|
556 | 960 |
), |
961 |
+ ) |
|
962 |
+ if overwrite_config: |
|
963 |
+ self.put_config(maybe_config) |
|
964 |
+ else: |
|
965 |
+ configuration = self.get_config() |
|
966 |
+ merged_config: collections.ChainMap[str, Any] = ( |
|
967 |
+ collections.ChainMap( |
|
968 |
+ { |
|
969 |
+ 'services': collections.ChainMap( |
|
970 |
+ maybe_config['services'], |
|
971 |
+ configuration['services'], |
|
557 | 972 |
), |
558 |
- cls=cli_machinery.StorageManagementOption, |
|
559 |
- shell_complete=cli_helpers.shell_complete_path, |
|
973 |
+ }, |
|
974 |
+ {'global': maybe_config['global']} |
|
975 |
+ if 'global' in maybe_config |
|
976 |
+ else {}, |
|
977 |
+ {'global': configuration['global']} |
|
978 |
+ if 'global' in configuration |
|
979 |
+ else {}, |
|
560 | 980 |
) |
561 |
-@click.option( |
|
562 |
- '--overwrite-existing/--merge-existing', |
|
563 |
- 'overwrite_config', |
|
564 |
- default=False, |
|
565 |
- help=_msg.TranslatedString( |
|
566 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT |
|
981 |
+ ) |
|
982 |
+ new_config: Any = { |
|
983 |
+ k: dict(v) if isinstance(v, collections.ChainMap) else v |
|
984 |
+ for k, v in sorted(merged_config.items()) |
|
985 |
+ } |
|
986 |
+ assert _types.is_vault_config(new_config) |
|
987 |
+ self.put_config(new_config) |
|
988 |
+ |
|
989 |
+ def run_op_export_settings(self) -> None: |
|
990 |
+ """Export settings to a given file. |
|
991 |
+ |
|
992 |
+ Respect the `--export-as` option when writing the exported |
|
993 |
+ configuration to the file. |
|
994 |
+ |
|
995 |
+ """ |
|
996 |
+ export_settings = self.ctx.params['export_settings'] |
|
997 |
+ export_as = self.ctx.params['export_as'] |
|
998 |
+ configuration = self.get_config() |
|
999 |
+ try: |
|
1000 |
+ # TODO(the-13th-letter): keep track of auto-close; try |
|
1001 |
+ # os.dup if feasible |
|
1002 |
+ outfile = cast( |
|
1003 |
+ 'TextIO', |
|
1004 |
+ ( |
|
1005 |
+ export_settings |
|
1006 |
+ if hasattr(export_settings, 'close') |
|
1007 |
+ else click.open_file(os.fspath(export_settings), 'wt') |
|
567 | 1008 |
), |
568 |
- cls=cli_machinery.CompatibilityOption, |
|
569 | 1009 |
) |
570 |
-@click.option( |
|
571 |
- '--unset', |
|
572 |
- 'unset_settings', |
|
573 |
- multiple=True, |
|
574 |
- type=click.Choice([ |
|
575 |
- 'phrase', |
|
576 |
- 'key', |
|
1010 |
+ # Don't specifically catch TypeError or ValueError here if |
|
1011 |
+ # the passed-in fileobj is not a writable text stream. This |
|
1012 |
+ # will never happen on the command-line (thanks to `click`), |
|
1013 |
+ # and for programmatic use, our caller may want accurate |
|
1014 |
+ # error information. |
|
1015 |
+ with outfile: |
|
1016 |
+ if export_as == 'sh': |
|
1017 |
+ this_ctx = self.ctx |
|
1018 |
+ prog_name_pieces = collections.deque([ |
|
1019 |
+ this_ctx.info_name or 'vault', |
|
1020 |
+ ]) |
|
1021 |
+ while ( |
|
1022 |
+ this_ctx.parent is not None |
|
1023 |
+ and this_ctx.parent.info_name is not None |
|
1024 |
+ ): |
|
1025 |
+ prog_name_pieces.appendleft(this_ctx.parent.info_name) |
|
1026 |
+ this_ctx = this_ctx.parent |
|
1027 |
+ cli_helpers.print_config_as_sh_script( |
|
1028 |
+ configuration, |
|
1029 |
+ outfile=outfile, |
|
1030 |
+ prog_name_list=prog_name_pieces, |
|
1031 |
+ ) |
|
1032 |
+ else: |
|
1033 |
+ json.dump( |
|
1034 |
+ configuration, |
|
1035 |
+ outfile, |
|
1036 |
+ ensure_ascii=False, |
|
1037 |
+ indent=2, |
|
1038 |
+ sort_keys=True, |
|
1039 |
+ ) |
|
1040 |
+ except OSError as exc: |
|
1041 |
+ self.err( |
|
1042 |
+ _msg.TranslatedString( |
|
1043 |
+ _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS, |
|
1044 |
+ error=exc.strerror, |
|
1045 |
+ filename=exc.filename, |
|
1046 |
+ ).maybe_without_filename(), |
|
1047 |
+ ) |
|
1048 |
+ |
|
1049 |
+ def run_subop_query_phrase_or_key_change( # noqa: C901,PLR0912 |
|
1050 |
+ self, |
|
1051 |
+ *, |
|
1052 |
+ empty_service_permitted: bool, |
|
1053 |
+ configuration: _types.VaultConfig | None = None, |
|
1054 |
+ ) -> collections.ChainMap[str, Any]: |
|
1055 |
+ """Query the master passphrase or master SSH key, if changed. |
|
1056 |
+ |
|
1057 |
+ If the user indicates they want to change the master passphrase |
|
1058 |
+ or master SSH key for this call, or if they want to configure |
|
1059 |
+ a stored global or service-specific master passphrase or master |
|
1060 |
+ SSH key, then query the user. |
|
1061 |
+ |
|
1062 |
+ This is not a complete command-line call operation in and of |
|
1063 |
+ itself. |
|
1064 |
+ |
|
1065 |
+ Args: |
|
1066 |
+ empty_service_permitted: |
|
1067 |
+ True if an empty service name is permitted, False otherwise. |
|
1068 |
+ configuration: |
|
1069 |
+ The vault configuration, parsed from disk. If not |
|
1070 |
+ given, we read the configuration from disk ourselves. |
|
1071 |
+ |
|
1072 |
+ The returned effective configuration already contains |
|
1073 |
+ a relevant slice of the vault configuration. However, |
|
1074 |
+ some callers need access to the full configuration *and* |
|
1075 |
+ need the slice within the effective configuration to |
|
1076 |
+ refer to the same object. |
|
1077 |
+ |
|
1078 |
+ Returns: |
|
1079 |
+ The effective configuration for the (possibly empty) given |
|
1080 |
+ service, as a [chained map][collections.ChainMap]. Any |
|
1081 |
+ master passphrase or master SSH key overrides that may be in |
|
1082 |
+ effect are stored in the first map. |
|
1083 |
+ |
|
1084 |
+ Raises: |
|
1085 |
+ click.UsageError: |
|
1086 |
+ The service name was empty, and an empty service name |
|
1087 |
+ was not permitted as per the method parameters. |
|
1088 |
+ |
|
1089 |
+ Warning: |
|
1090 |
+ It is the caller's responsibility to vet any interactively |
|
1091 |
+ entered master passphrase for Unicode normalization issues. |
|
1092 |
+ |
|
1093 |
+ """ |
|
1094 |
+ service = self.ctx.params['service'] |
|
1095 |
+ use_key = self.ctx.params['use_key'] |
|
1096 |
+ use_phrase = self.ctx.params['use_phrase'] |
|
1097 |
+ if configuration is None: |
|
1098 |
+ configuration = self.get_config() |
|
1099 |
+ service_keys_on_commandline = { |
|
577 | 1100 |
'length', |
578 | 1101 |
'repeat', |
579 | 1102 |
'lower', |
... | ... |
@@ -582,706 +1105,329 @@ def derivepassphrase_export_vault( |
582 | 1105 |
'space', |
583 | 1106 |
'dash', |
584 | 1107 |
'symbol', |
585 |
- 'notes', |
|
586 |
- ]), |
|
587 |
- help=_msg.TranslatedString( |
|
588 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT |
|
1108 |
+ } |
|
1109 |
+ settings: collections.ChainMap[str, Any] = collections.ChainMap( |
|
1110 |
+ { |
|
1111 |
+ k: v |
|
1112 |
+ for k in service_keys_on_commandline |
|
1113 |
+ if (v := self.ctx.params.get(k)) is not None |
|
1114 |
+ }, |
|
1115 |
+ cast( |
|
1116 |
+ 'dict[str, Any]', |
|
1117 |
+ configuration['services'].get(service, {}) if service else {}, |
|
589 | 1118 |
), |
590 |
- cls=cli_machinery.CompatibilityOption, |
|
1119 |
+ cast('dict[str, Any]', configuration.get('global', {})), |
|
591 | 1120 |
) |
592 |
-@click.option( |
|
593 |
- '--export-as', |
|
594 |
- type=click.Choice(['json', 'sh']), |
|
595 |
- default='json', |
|
596 |
- help=_msg.TranslatedString( |
|
597 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT |
|
1121 |
+ if not service and not empty_service_permitted: |
|
1122 |
+ err_msg = _msg.TranslatedString( |
|
1123 |
+ _msg.ErrMsgTemplate.SERVICE_REQUIRED, |
|
1124 |
+ service_metavar=_msg.TranslatedString( |
|
1125 |
+ _msg.Label.VAULT_METAVAR_SERVICE |
|
598 | 1126 |
), |
599 |
- cls=cli_machinery.CompatibilityOption, |
|
600 | 1127 |
) |
601 |
-@click.option( |
|
602 |
- '--modern-editor-interface/--vault-legacy-editor-interface', |
|
603 |
- 'modern_editor_interface', |
|
604 |
- default=False, |
|
605 |
- help=_msg.TranslatedString( |
|
606 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_EDITOR_INTERFACE_HELP_TEXT |
|
1128 |
+ raise click.UsageError(str(err_msg)) |
|
1129 |
+ if use_key: |
|
1130 |
+ try: |
|
1131 |
+ settings.maps[0]['key'] = base64.standard_b64encode( |
|
1132 |
+ cli_helpers.select_ssh_key(ctx=self.ctx) |
|
1133 |
+ ).decode('ASCII') |
|
1134 |
+ except IndexError: |
|
1135 |
+ self.err( |
|
1136 |
+ _msg.TranslatedString( |
|
1137 |
+ _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION |
|
607 | 1138 |
), |
608 |
- cls=cli_machinery.CompatibilityOption, |
|
609 | 1139 |
) |
610 |
-@click.option( |
|
611 |
- '--print-notes-before/--print-notes-after', |
|
612 |
- 'print_notes_before', |
|
613 |
- default=False, |
|
614 |
- help=_msg.TranslatedString( |
|
615 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT |
|
1140 |
+ except KeyError: |
|
1141 |
+ self.err( |
|
1142 |
+ _msg.TranslatedString( |
|
1143 |
+ _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND |
|
616 | 1144 |
), |
617 |
- cls=cli_machinery.CompatibilityOption, |
|
618 | 1145 |
) |
619 |
-@cli_machinery.version_option(cli_machinery.vault_version_option_callback) |
|
620 |
-@cli_machinery.color_forcing_pseudo_option |
|
621 |
-@cli_machinery.standard_logging_options |
|
622 |
-@click.argument( |
|
623 |
- 'service', |
|
624 |
- metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE), |
|
625 |
- required=False, |
|
626 |
- default=None, |
|
627 |
- shell_complete=cli_helpers.shell_complete_service, |
|
1146 |
+ except LookupError: |
|
1147 |
+ self.err( |
|
1148 |
+ _msg.TranslatedString( |
|
1149 |
+ _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS, |
|
1150 |
+ PROG_NAME=PROG_NAME, |
|
628 | 1151 |
) |
629 |
-@click.pass_context |
|
630 |
-def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
|
631 |
- ctx: click.Context, |
|
632 |
- /, |
|
633 |
- *, |
|
634 |
- service: str | None = None, |
|
635 |
- use_phrase: bool = False, |
|
636 |
- use_key: bool = False, |
|
637 |
- length: int | None = None, |
|
638 |
- repeat: int | None = None, |
|
639 |
- lower: int | None = None, |
|
640 |
- upper: int | None = None, |
|
641 |
- number: int | None = None, |
|
642 |
- space: int | None = None, |
|
643 |
- dash: int | None = None, |
|
644 |
- symbol: int | None = None, |
|
645 |
- edit_notes: bool = False, |
|
646 |
- store_config_only: bool = False, |
|
647 |
- delete_service_settings: bool = False, |
|
648 |
- delete_globals: bool = False, |
|
649 |
- clear_all_settings: bool = False, |
|
650 |
- export_settings: TextIO | os.PathLike[str] | None = None, |
|
651 |
- import_settings: TextIO | os.PathLike[str] | None = None, |
|
652 |
- overwrite_config: bool = False, |
|
653 |
- unset_settings: Sequence[str] = (), |
|
654 |
- export_as: Literal['json', 'sh'] = 'json', |
|
655 |
- modern_editor_interface: bool = False, |
|
656 |
- print_notes_before: bool = False, |
|
657 |
-) -> None: |
|
658 |
- """Derive a passphrase using the vault(1) derivation scheme. |
|
1152 |
+ ) |
|
1153 |
+ except NotImplementedError: |
|
1154 |
+ self.err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX)) |
|
1155 |
+ except OSError as exc: |
|
1156 |
+ self.err( |
|
1157 |
+ _msg.TranslatedString( |
|
1158 |
+ _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT, |
|
1159 |
+ error=exc.strerror, |
|
1160 |
+ filename=exc.filename, |
|
1161 |
+ ).maybe_without_filename(), |
|
1162 |
+ ) |
|
1163 |
+ except ssh_agent.SSHAgentFailedError as exc: |
|
1164 |
+ self.err( |
|
1165 |
+ _msg.TranslatedString( |
|
1166 |
+ _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS |
|
1167 |
+ ), |
|
1168 |
+ exc_info=exc, |
|
1169 |
+ ) |
|
1170 |
+ except RuntimeError as exc: |
|
1171 |
+ self.err( |
|
1172 |
+ _msg.TranslatedString( |
|
1173 |
+ _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT |
|
1174 |
+ ), |
|
1175 |
+ exc_info=exc, |
|
1176 |
+ ) |
|
1177 |
+ elif use_phrase: |
|
1178 |
+ maybe_phrase = cli_helpers.prompt_for_passphrase() |
|
1179 |
+ if not maybe_phrase: |
|
1180 |
+ self.err( |
|
1181 |
+ _msg.TranslatedString( |
|
1182 |
+ _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE |
|
1183 |
+ ) |
|
1184 |
+ ) |
|
1185 |
+ else: |
|
1186 |
+ settings.maps[0]['phrase'] = maybe_phrase |
|
1187 |
+ return settings |
|
659 | 1188 |
|
660 |
- This is a [`click`][CLICK]-powered command-line interface function, |
|
661 |
- and not intended for programmatic use. See the |
|
662 |
- derivepassphrase-vault(1) manpage for full documentation of the |
|
663 |
- interface. (See also [`click.testing.CliRunner`][] for controlled, |
|
664 |
- programmatic invocation.) |
|
665 |
- |
|
666 |
- [CLICK]: https://pypi.org/package/click/ |
|
1189 |
+ def run_op_store_config_only(self) -> None: # noqa: C901,PLR0912,PLR0914,PLR0915 |
|
1190 |
+ """Update the stored vault configuration. |
|
667 | 1191 |
|
668 |
- Parameters: |
|
669 |
- ctx (click.Context): |
|
670 |
- The `click` context. |
|
1192 |
+ Depending on the presence or the absence of a service name, |
|
1193 |
+ update either the service-specific settings, or the global |
|
1194 |
+ settings. (An empty service name is respected, i.e., updates |
|
1195 |
+ the former.) |
|
671 | 1196 |
|
672 |
- Other Parameters: |
|
673 |
- service: |
|
674 |
- A service name. Required, unless operating on global |
|
675 |
- settings or importing/exporting settings. |
|
676 |
- use_phrase: |
|
677 |
- Command-line argument `-p`/`--phrase`. If given, query the |
|
678 |
- user for a passphrase instead of an SSH key. |
|
679 |
- use_key: |
|
680 |
- Command-line argument `-k`/`--key`. If given, query the |
|
681 |
- user for an SSH key instead of a passphrase. |
|
682 |
- length: |
|
683 |
- Command-line argument `-l`/`--length`. Override the default |
|
684 |
- length of the generated passphrase. |
|
685 |
- repeat: |
|
686 |
- Command-line argument `-r`/`--repeat`. Override the default |
|
687 |
- repetition limit if positive, or disable the repetition |
|
688 |
- limit if 0. |
|
689 |
- lower: |
|
690 |
- Command-line argument `--lower`. Require a given amount of |
|
691 |
- ASCII lowercase characters if positive, else forbid ASCII |
|
692 |
- lowercase characters if 0. |
|
693 |
- upper: |
|
694 |
- Command-line argument `--upper`. Same as `lower`, but for |
|
695 |
- ASCII uppercase characters. |
|
696 |
- number: |
|
697 |
- Command-line argument `--number`. Same as `lower`, but for |
|
698 |
- ASCII digits. |
|
699 |
- space: |
|
700 |
- Command-line argument `--space`. Same as `lower`, but for |
|
701 |
- the space character. |
|
702 |
- dash: |
|
703 |
- Command-line argument `--dash`. Same as `lower`, but for |
|
704 |
- the hyphen-minus and underscore characters. |
|
705 |
- symbol: |
|
706 |
- Command-line argument `--symbol`. Same as `lower`, but for |
|
707 |
- all other ASCII printable characters except lowercase |
|
708 |
- characters, uppercase characters, digits, space and |
|
709 |
- backquote. |
|
710 |
- edit_notes: |
|
711 |
- Command-line argument `-n`/`--notes`. If given, spawn an |
|
712 |
- editor to edit notes for `service`. |
|
713 |
- store_config_only: |
|
714 |
- Command-line argument `-c`/`--config`. If given, saves the |
|
715 |
- other given settings (`--key`, ..., `--symbol`) to the |
|
716 |
- configuration file, either specifically for `service` or as |
|
717 |
- global settings. |
|
718 |
- delete_service_settings: |
|
719 |
- Command-line argument `-x`/`--delete`. If given, removes |
|
720 |
- the settings for `service` from the configuration file. |
|
721 |
- delete_globals: |
|
722 |
- Command-line argument `--delete-globals`. If given, removes |
|
723 |
- the global settings from the configuration file. |
|
724 |
- clear_all_settings: |
|
725 |
- Command-line argument `-X`/`--clear`. If given, removes all |
|
726 |
- settings from the configuration file. |
|
727 |
- export_settings: |
|
728 |
- Command-line argument `-e`/`--export`. If a file object, |
|
729 |
- then it must be open for writing and accept `str` inputs. |
|
730 |
- Otherwise, a filename to open for writing. Using `-` for |
|
731 |
- standard output is supported. |
|
732 |
- import_settings: |
|
733 |
- Command-line argument `-i`/`--import`. If a file object, it |
|
734 |
- must be open for reading and yield `str` values. Otherwise, |
|
735 |
- a filename to open for reading. Using `-` for standard |
|
736 |
- input is supported. |
|
737 |
- overwrite_config: |
|
738 |
- Command-line arguments `--overwrite-existing` (True) and |
|
739 |
- `--merge-existing` (False). Controls whether config saving |
|
740 |
- and config importing overwrite existing configurations, or |
|
741 |
- merge them section-wise instead. |
|
742 |
- unset_settings: |
|
743 |
- Command-line argument `--unset`. If given together with |
|
744 |
- `--config`, unsets the specified settings (in addition to |
|
745 |
- any other changes requested). |
|
746 |
- export_as: |
|
747 |
- Command-line argument `--export-as`. If given together with |
|
748 |
- `--export`, selects the format to export the current |
|
749 |
- configuration as: JSON ("json", default) or POSIX sh ("sh"). |
|
750 |
- modern_editor_interface: |
|
751 |
- Command-line arguments `--modern-editor-interface` (True) |
|
752 |
- and `--vault-legacy-editor-interface` (False). Controls |
|
753 |
- whether editing notes uses a modern editor interface |
|
754 |
- (supporting comments and aborting) or a vault(1)-compatible |
|
755 |
- legacy editor interface (WYSIWYG notes contents). |
|
756 |
- print_notes_before: |
|
757 |
- Command-line arguments `--print-notes-before` (True) and |
|
758 |
- `--print-notes-after` (False). Controls whether the service |
|
759 |
- notes (if any) are printed before the passphrase, or after. |
|
1197 |
+ Respect the `--unset=SETTING` option to unset the named |
|
1198 |
+ settings, and the `--notes` option to edit notes interactively |
|
1199 |
+ in a spawned text editor. Issue multiple warnings, if |
|
1200 |
+ appropriate, e.g. for Unicode normalization issues with stored |
|
1201 |
+ passphrases or conflicting stored passphrases and keys. Respect |
|
1202 |
+ the `--overwrite-config` and `--merge-config` options when |
|
1203 |
+ writing the imported configuration to disk, and the |
|
1204 |
+ `--modern-editor-interface` and |
|
1205 |
+ `--vault-legacy-editor-interface` options when editing notes. |
|
760 | 1206 |
|
761 |
- """ # noqa: DOC501 |
|
762 |
- logger = logging.getLogger(PROG_NAME) |
|
763 |
- deprecation = logging.getLogger(PROG_NAME + '.deprecation') |
|
764 |
- service_metavar = _msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE) |
|
765 |
- options_in_group: dict[type[click.Option], list[click.Option]] = {} |
|
766 |
- params_by_str: dict[str, click.Parameter] = {} |
|
767 |
- for param in ctx.command.params: |
|
768 |
- if isinstance(param, click.Option): |
|
769 |
- group: type[click.Option] |
|
770 |
- known_option_groups = [ |
|
771 |
- cli_machinery.PassphraseGenerationOption, |
|
772 |
- cli_machinery.ConfigurationOption, |
|
773 |
- cli_machinery.StorageManagementOption, |
|
774 |
- cli_machinery.LoggingOption, |
|
775 |
- cli_machinery.CompatibilityOption, |
|
776 |
- cli_machinery.StandardOption, |
|
777 |
- ] |
|
778 |
- if isinstance(param, cli_machinery.OptionGroupOption): |
|
779 |
- for class_ in known_option_groups: |
|
780 |
- if isinstance(param, class_): |
|
781 |
- group = class_ |
|
782 |
- break |
|
783 |
- else: # pragma: no cover |
|
784 |
- raise AssertionError( # noqa: TRY003 |
|
785 |
- f'Unknown option group for {param!r}' # noqa: EM102 |
|
786 |
- ) |
|
787 |
- else: |
|
788 |
- group = click.Option |
|
789 |
- options_in_group.setdefault(group, []).append(param) |
|
790 |
- params_by_str[param.human_readable_name] = param |
|
791 |
- for name in param.opts + param.secondary_opts: |
|
792 |
- params_by_str[name] = param |
|
793 |
- |
|
794 |
- @functools.cache |
|
795 |
- def is_param_set(param: click.Parameter) -> bool: |
|
796 |
- return bool(ctx.params.get(param.human_readable_name)) |
|
797 |
- |
|
798 |
- def option_name(param: click.Parameter | str) -> str: |
|
799 |
- # Annoyingly, `param.human_readable_name` contains the *function* |
|
800 |
- # parameter name, not the list of option names. *Those* are |
|
801 |
- # stashed in the `.opts` and `.secondary_opts` attributes, which |
|
802 |
- # are visible in the `.to_info_dict()` output, but not otherwise |
|
803 |
- # documented. |
|
804 |
- param = params_by_str[param] if isinstance(param, str) else param |
|
805 |
- names = [param.human_readable_name, *param.opts, *param.secondary_opts] |
|
806 |
- option_names = [n for n in names if n.startswith('--')] |
|
807 |
- return min(option_names, key=len) |
|
1207 |
+ Raises: |
|
1208 |
+ click.UsageError: |
|
1209 |
+ The user requested that the same setting be both unset |
|
1210 |
+ and set. |
|
808 | 1211 |
|
809 |
- def check_incompatible_options( |
|
810 |
- param1: click.Parameter | str, |
|
811 |
- param2: click.Parameter | str, |
|
812 |
- ) -> None: |
|
813 |
- param1 = params_by_str[param1] if isinstance(param1, str) else param1 |
|
814 |
- param2 = params_by_str[param2] if isinstance(param2, str) else param2 |
|
815 |
- if param1 == param2: |
|
816 |
- return |
|
817 |
- if not is_param_set(param1): |
|
818 |
- return |
|
819 |
- if is_param_set(param2): |
|
820 |
- param1_str = option_name(param1) |
|
821 |
- param2_str = option_name(param2) |
|
822 |
- raise click.BadOptionUsage( |
|
823 |
- param1_str, |
|
824 |
- str( |
|
825 |
- _msg.TranslatedString( |
|
826 |
- _msg.ErrMsgTemplate.PARAMS_MUTUALLY_EXCLUSIVE, |
|
827 |
- param1=param1_str, |
|
828 |
- param2=param2_str, |
|
829 |
- ) |
|
830 |
- ), |
|
831 |
- ctx=ctx, |
|
1212 |
+ """ |
|
1213 |
+ service = self.ctx.params['service'] |
|
1214 |
+ use_key = self.ctx.params['use_key'] |
|
1215 |
+ use_phrase = self.ctx.params['use_phrase'] |
|
1216 |
+ unset_settings = self.ctx.params['unset_settings'] |
|
1217 |
+ overwrite_config = self.ctx.params['overwrite_config'] |
|
1218 |
+ edit_notes = self.ctx.params['edit_notes'] |
|
1219 |
+ modern_editor_interface = self.ctx.params['modern_editor_interface'] |
|
1220 |
+ configuration = self.get_config() |
|
1221 |
+ user_config = self.get_user_config() |
|
1222 |
+ settings = self.run_subop_query_phrase_or_key_change( |
|
1223 |
+ configuration=configuration, empty_service_permitted=True |
|
1224 |
+ ) |
|
1225 |
+ overrides = settings.maps[0] |
|
1226 |
+ view: collections.ChainMap[str, Any] |
|
1227 |
+ view = ( |
|
1228 |
+ collections.ChainMap(*settings.maps[:2]) |
|
1229 |
+ if service |
|
1230 |
+ else collections.ChainMap(settings.maps[0], settings.maps[2]) |
|
832 | 1231 |
) |
833 |
- return |
|
834 |
- |
|
835 |
- def err(msg: Any, /, **kwargs: Any) -> NoReturn: # noqa: ANN401 |
|
836 |
- stacklevel = kwargs.pop('stacklevel', 1) |
|
837 |
- stacklevel += 1 |
|
838 |
- extra = kwargs.pop('extra', {}) |
|
839 |
- extra.setdefault('color', ctx.color) |
|
840 |
- logger.error(msg, stacklevel=stacklevel, extra=extra, **kwargs) |
|
841 |
- ctx.exit(1) |
|
842 |
- |
|
843 |
- def get_config() -> _types.VaultConfig: |
|
844 |
- try: |
|
845 |
- return cli_helpers.load_config() |
|
846 |
- except FileNotFoundError: |
|
1232 |
+ if use_key: |
|
1233 |
+ view['key'] = overrides['key'] |
|
1234 |
+ elif use_phrase: |
|
1235 |
+ view['phrase'] = overrides['phrase'] |
|
847 | 1236 |
try: |
848 |
- backup_config, exc = cli_helpers.migrate_and_load_old_config() |
|
849 |
- except FileNotFoundError: |
|
850 |
- return {'services': {}} |
|
851 |
- old_name = cli_helpers.config_filename( |
|
852 |
- subsystem='old settings.json' |
|
853 |
- ).name |
|
854 |
- new_name = cli_helpers.config_filename(subsystem='vault').name |
|
855 |
- deprecation.warning( |
|
856 |
- _msg.TranslatedString( |
|
857 |
- _msg.WarnMsgTemplate.V01_STYLE_CONFIG, |
|
858 |
- old=old_name, |
|
859 |
- new=new_name, |
|
860 |
- ), |
|
861 |
- extra={'color': ctx.color}, |
|
1237 |
+ cli_helpers.check_for_misleading_passphrase( |
|
1238 |
+ ('services', service) if service else ('global',), |
|
1239 |
+ overrides, |
|
1240 |
+ main_config=user_config, |
|
1241 |
+ ctx=self.ctx, |
|
862 | 1242 |
) |
863 |
- if isinstance(exc, OSError): |
|
864 |
- logger.warning( |
|
1243 |
+ except AssertionError as exc: |
|
1244 |
+ self.err( |
|
865 | 1245 |
_msg.TranslatedString( |
866 |
- _msg.WarnMsgTemplate.FAILED_TO_MIGRATE_CONFIG, |
|
867 |
- path=new_name, |
|
868 |
- error=exc.strerror, |
|
869 |
- filename=exc.filename, |
|
1246 |
+ _msg.ErrMsgTemplate.INVALID_USER_CONFIG, |
|
1247 |
+ error=exc, |
|
1248 |
+ filename=None, |
|
870 | 1249 |
).maybe_without_filename(), |
871 |
- extra={'color': ctx.color}, |
|
1250 |
+ ) |
|
1251 |
+ if 'key' in settings: |
|
1252 |
+ if service: |
|
1253 |
+ w_msg = _msg.TranslatedString( |
|
1254 |
+ _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, |
|
1255 |
+ service=json.dumps(service), |
|
872 | 1256 |
) |
873 | 1257 |
else: |
874 |
- deprecation.info( |
|
875 |
- _msg.TranslatedString( |
|
876 |
- _msg.InfoMsgTemplate.SUCCESSFULLY_MIGRATED, |
|
877 |
- path=new_name, |
|
1258 |
+ w_msg = _msg.TranslatedString( |
|
1259 |
+ _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE |
|
1260 |
+ ) |
|
1261 |
+ self.warning(w_msg) |
|
1262 |
+ if not view.maps[0] and not unset_settings and not edit_notes: |
|
1263 |
+ err_msg = _msg.TranslatedString( |
|
1264 |
+ _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS, |
|
1265 |
+ settings_type=_msg.TranslatedString( |
|
1266 |
+ _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_SERVICE |
|
1267 |
+ if service |
|
1268 |
+ else _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_GLOBAL # noqa: E501 |
|
878 | 1269 |
), |
879 |
- extra={'color': ctx.color}, |
|
880 | 1270 |
) |
881 |
- return backup_config |
|
882 |
- except OSError as exc: |
|
883 |
- err( |
|
884 |
- _msg.TranslatedString( |
|
885 |
- _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, |
|
886 |
- error=exc.strerror, |
|
887 |
- filename=exc.filename, |
|
888 |
- ).maybe_without_filename(), |
|
1271 |
+ raise click.UsageError(str(err_msg)) |
|
1272 |
+ for setting in unset_settings: |
|
1273 |
+ if setting in view.maps[0]: |
|
1274 |
+ err_msg = _msg.TranslatedString( |
|
1275 |
+ _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING, |
|
1276 |
+ setting=setting, |
|
889 | 1277 |
) |
890 |
- except Exception as exc: # noqa: BLE001 |
|
891 |
- err( |
|
1278 |
+ raise click.UsageError(str(err_msg)) |
|
1279 |
+ if not cli_helpers.is_completable_item(service): |
|
1280 |
+ self.warning( |
|
892 | 1281 |
_msg.TranslatedString( |
893 |
- _msg.ErrMsgTemplate.CANNOT_LOAD_VAULT_SETTINGS, |
|
894 |
- error=str(exc), |
|
895 |
- filename=None, |
|
896 |
- ).maybe_without_filename(), |
|
897 |
- exc_info=exc, |
|
1282 |
+ _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
|
1283 |
+ service=service, |
|
1284 |
+ ), |
|
898 | 1285 |
) |
899 |
- |
|
900 |
- def put_config(config: _types.VaultConfig, /) -> None: |
|
901 |
- try: |
|
902 |
- cli_helpers.save_config(config) |
|
903 |
- except OSError as exc: |
|
904 |
- err( |
|
905 |
- _msg.TranslatedString( |
|
906 |
- _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, |
|
907 |
- error=exc.strerror, |
|
908 |
- filename=exc.filename, |
|
909 |
- ).maybe_without_filename(), |
|
1286 |
+ subtree: dict[str, Any] = ( |
|
1287 |
+ configuration['services'].setdefault(service, {}) # type: ignore[assignment] |
|
1288 |
+ if service |
|
1289 |
+ else configuration.setdefault('global', {}) |
|
910 | 1290 |
) |
911 |
- except Exception as exc: # noqa: BLE001 |
|
912 |
- err( |
|
913 |
- _msg.TranslatedString( |
|
914 |
- _msg.ErrMsgTemplate.CANNOT_STORE_VAULT_SETTINGS, |
|
915 |
- error=str(exc), |
|
916 |
- filename=None, |
|
917 |
- ).maybe_without_filename(), |
|
918 |
- exc_info=exc, |
|
1291 |
+ if overwrite_config: |
|
1292 |
+ subtree.clear() |
|
1293 |
+ else: |
|
1294 |
+ for setting in unset_settings: |
|
1295 |
+ subtree.pop(setting, None) |
|
1296 |
+ subtree.update(view) |
|
1297 |
+ assert _types.is_vault_config(configuration), ( |
|
1298 |
+ f'Invalid vault configuration: {configuration!r}' |
|
919 | 1299 |
) |
920 |
- |
|
921 |
- def get_user_config() -> dict[str, Any]: |
|
922 |
- try: |
|
923 |
- return cli_helpers.load_user_config() |
|
924 |
- except FileNotFoundError: |
|
925 |
- return {} |
|
926 |
- except OSError as exc: |
|
927 |
- err( |
|
928 |
- _msg.TranslatedString( |
|
929 |
- _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, |
|
930 |
- error=exc.strerror, |
|
931 |
- filename=exc.filename, |
|
932 |
- ).maybe_without_filename(), |
|
933 |
- ) |
|
934 |
- except Exception as exc: # noqa: BLE001 |
|
935 |
- err( |
|
936 |
- _msg.TranslatedString( |
|
937 |
- _msg.ErrMsgTemplate.CANNOT_LOAD_USER_CONFIG, |
|
938 |
- error=str(exc), |
|
939 |
- filename=None, |
|
940 |
- ).maybe_without_filename(), |
|
941 |
- exc_info=exc, |
|
942 |
- ) |
|
943 |
- |
|
944 |
- configuration: _types.VaultConfig |
|
945 |
- |
|
946 |
- check_incompatible_options('--phrase', '--key') |
|
947 |
- for group in ( |
|
948 |
- cli_machinery.ConfigurationOption, |
|
949 |
- cli_machinery.StorageManagementOption, |
|
950 |
- ): |
|
951 |
- for opt in options_in_group[group]: |
|
952 |
- if opt not in { |
|
953 |
- params_by_str['--config'], |
|
954 |
- params_by_str['--notes'], |
|
955 |
- }: |
|
956 |
- for other_opt in options_in_group[ |
|
957 |
- cli_machinery.PassphraseGenerationOption |
|
958 |
- ]: |
|
959 |
- check_incompatible_options(opt, other_opt) |
|
960 |
- |
|
961 |
- for group in ( |
|
962 |
- cli_machinery.ConfigurationOption, |
|
963 |
- cli_machinery.StorageManagementOption, |
|
964 |
- ): |
|
965 |
- for opt in options_in_group[group]: |
|
966 |
- for other_opt in options_in_group[ |
|
967 |
- cli_machinery.ConfigurationOption |
|
968 |
- ]: |
|
969 |
- if {opt, other_opt} != { |
|
970 |
- params_by_str['--config'], |
|
971 |
- params_by_str['--notes'], |
|
972 |
- }: |
|
973 |
- check_incompatible_options(opt, other_opt) |
|
974 |
- for other_opt in options_in_group[ |
|
975 |
- cli_machinery.StorageManagementOption |
|
976 |
- ]: |
|
977 |
- check_incompatible_options(opt, other_opt) |
|
978 |
- sv_or_global_options = options_in_group[ |
|
979 |
- cli_machinery.PassphraseGenerationOption |
|
980 |
- ] |
|
981 |
- for param in sv_or_global_options: |
|
982 |
- if is_param_set(param) and not ( |
|
983 |
- service is not None or is_param_set(params_by_str['--config']) |
|
984 |
- ): |
|
985 |
- err_msg = _msg.TranslatedString( |
|
986 |
- _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE_OR_CONFIG, |
|
987 |
- param=param.opts[0], |
|
988 |
- service_metavar=service_metavar, |
|
989 |
- ) |
|
990 |
- raise click.UsageError(str(err_msg)) |
|
991 |
- sv_options = [params_by_str['--notes'], params_by_str['--delete']] |
|
992 |
- for param in sv_options: |
|
993 |
- if is_param_set(param) and not service is not None: |
|
994 |
- err_msg = _msg.TranslatedString( |
|
995 |
- _msg.ErrMsgTemplate.PARAMS_NEEDS_SERVICE, |
|
996 |
- param=param.opts[0], |
|
997 |
- service_metavar=service_metavar, |
|
998 |
- ) |
|
999 |
- raise click.UsageError(str(err_msg)) |
|
1000 |
- no_sv_options = [ |
|
1001 |
- params_by_str['--delete-globals'], |
|
1002 |
- params_by_str['--clear'], |
|
1003 |
- *options_in_group[cli_machinery.StorageManagementOption], |
|
1004 |
- ] |
|
1005 |
- for param in no_sv_options: |
|
1006 |
- if is_param_set(param) and service is not None: |
|
1007 |
- err_msg = _msg.TranslatedString( |
|
1008 |
- _msg.ErrMsgTemplate.PARAMS_NO_SERVICE, |
|
1009 |
- param=param.opts[0], |
|
1010 |
- service_metavar=service_metavar, |
|
1011 |
- ) |
|
1012 |
- raise click.UsageError(str(err_msg)) |
|
1013 |
- |
|
1014 |
- user_config = get_user_config() |
|
1015 |
- |
|
1016 |
- if service == '': # noqa: PLC1901 |
|
1017 |
- logger.warning( |
|
1018 |
- _msg.TranslatedString( |
|
1019 |
- _msg.WarnMsgTemplate.EMPTY_SERVICE_NOT_SUPPORTED, |
|
1020 |
- service_metavar=service_metavar, |
|
1021 |
- ), |
|
1022 |
- extra={'color': ctx.color}, |
|
1023 |
- ) |
|
1024 |
- |
|
1025 |
- if edit_notes and not store_config_only: |
|
1026 |
- logger.warning( |
|
1027 |
- _msg.TranslatedString( |
|
1028 |
- _msg.WarnMsgTemplate.EDITING_NOTES_BUT_NOT_STORING_CONFIG, |
|
1029 |
- service_metavar=service_metavar, |
|
1030 |
- ), |
|
1031 |
- extra={'color': ctx.color}, |
|
1032 |
- ) |
|
1033 |
- |
|
1034 |
- readwrite_ops = [ |
|
1035 |
- delete_service_settings, |
|
1036 |
- delete_globals, |
|
1037 |
- clear_all_settings, |
|
1038 |
- import_settings, |
|
1039 |
- store_config_only, |
|
1040 |
- ] |
|
1041 |
- mutex: Callable[[], contextlib.AbstractContextManager[None]] = ( |
|
1042 |
- cli_helpers.configuration_mutex |
|
1043 |
- if any(readwrite_ops) |
|
1044 |
- else contextlib.nullcontext |
|
1045 |
- ) |
|
1046 |
- |
|
1047 |
- with mutex(): # noqa: PLR1702 |
|
1048 |
- if delete_service_settings: |
|
1300 |
+ if edit_notes: |
|
1049 | 1301 |
assert service is not None |
1050 |
- configuration = get_config() |
|
1051 |
- if service in configuration['services']: |
|
1052 |
- del configuration['services'][service] |
|
1053 |
- put_config(configuration) |
|
1054 |
- elif delete_globals: |
|
1055 |
- configuration = get_config() |
|
1056 |
- if 'global' in configuration: |
|
1057 |
- del configuration['global'] |
|
1058 |
- put_config(configuration) |
|
1059 |
- elif clear_all_settings: |
|
1060 |
- put_config({'services': {}}) |
|
1061 |
- elif import_settings: |
|
1062 |
- try: |
|
1063 |
- # TODO(the-13th-letter): keep track of auto-close; try |
|
1064 |
- # os.dup if feasible |
|
1065 |
- infile = cast( |
|
1066 |
- 'TextIO', |
|
1067 |
- ( |
|
1068 |
- import_settings |
|
1069 |
- if hasattr(import_settings, 'close') |
|
1070 |
- else click.open_file(os.fspath(import_settings), 'rt') |
|
1071 |
- ), |
|
1302 |
+ notes_instructions = _msg.TranslatedString( |
|
1303 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_INSTRUCTION_TEXT |
|
1072 | 1304 |
) |
1073 |
- # Don't specifically catch TypeError or ValueError here if |
|
1074 |
- # the passed-in fileobj is not a readable text stream. This |
|
1075 |
- # will never happen on the command-line (thanks to `click`), |
|
1076 |
- # and for programmatic use, our caller may want accurate |
|
1077 |
- # error information. |
|
1078 |
- with infile: |
|
1079 |
- maybe_config = json.load(infile) |
|
1080 |
- except json.JSONDecodeError as exc: |
|
1081 |
- err( |
|
1082 |
- _msg.TranslatedString( |
|
1083 |
- _msg.ErrMsgTemplate.CANNOT_DECODEIMPORT_VAULT_SETTINGS, |
|
1084 |
- error=exc, |
|
1305 |
+ notes_marker = _msg.TranslatedString( |
|
1306 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1085 | 1307 |
) |
1308 |
+ notes_legacy_instructions = _msg.TranslatedString( |
|
1309 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT |
|
1086 | 1310 |
) |
1087 |
- except OSError as exc: |
|
1088 |
- err( |
|
1089 |
- _msg.TranslatedString( |
|
1090 |
- _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, |
|
1091 |
- error=exc.strerror, |
|
1092 |
- filename=exc.filename, |
|
1093 |
- ).maybe_without_filename() |
|
1311 |
+ old_notes_value = subtree.get('notes', '') |
|
1312 |
+ if modern_editor_interface: |
|
1313 |
+ text = '\n'.join([ |
|
1314 |
+ str(notes_instructions), |
|
1315 |
+ str(notes_marker), |
|
1316 |
+ old_notes_value, |
|
1317 |
+ ]) |
|
1318 |
+ else: |
|
1319 |
+ text = old_notes_value or str(notes_legacy_instructions) |
|
1320 |
+ notes_value = click.edit(text=text, require_save=False) |
|
1321 |
+ assert notes_value is not None |
|
1322 |
+ if ( |
|
1323 |
+ not modern_editor_interface |
|
1324 |
+ and notes_value.strip() != old_notes_value.strip() |
|
1325 |
+ ): |
|
1326 |
+ backup_file = cli_helpers.config_filename( |
|
1327 |
+ subsystem='notes backup' |
|
1094 | 1328 |
) |
1095 |
- cleaned = _types.clean_up_falsy_vault_config_values(maybe_config) |
|
1096 |
- if not _types.is_vault_config(maybe_config): |
|
1097 |
- err( |
|
1329 |
+ backup_file.write_text(old_notes_value, encoding='UTF-8') |
|
1330 |
+ self.warning( |
|
1098 | 1331 |
_msg.TranslatedString( |
1099 |
- _msg.ErrMsgTemplate.CANNOT_IMPORT_VAULT_SETTINGS, |
|
1100 |
- error=_msg.TranslatedString( |
|
1101 |
- _msg.ErrMsgTemplate.INVALID_VAULT_CONFIG, |
|
1102 |
- config=maybe_config, |
|
1332 |
+ _msg.WarnMsgTemplate.LEGACY_EDITOR_INTERFACE_NOTES_BACKUP, |
|
1333 |
+ filename=str(backup_file), |
|
1103 | 1334 |
), |
1104 |
- filename=None, |
|
1105 |
- ).maybe_without_filename() |
|
1106 | 1335 |
) |
1107 |
- assert cleaned is not None |
|
1108 |
- for step in cleaned: |
|
1109 |
- # These are never fatal errors, because the semantics of |
|
1110 |
- # vault upon encountering these settings are ill-specified, |
|
1111 |
- # but not ill-defined. |
|
1112 |
- if step.action == 'replace': |
|
1113 |
- logger.warning( |
|
1114 |
- _msg.TranslatedString( |
|
1115 |
- _msg.WarnMsgTemplate.STEP_REPLACE_INVALID_VALUE, |
|
1116 |
- old=json.dumps(step.old_value), |
|
1117 |
- path=_types.json_path(step.path), |
|
1118 |
- new=json.dumps(step.new_value), |
|
1119 |
- ), |
|
1120 |
- extra={'color': ctx.color}, |
|
1336 |
+ subtree['notes'] = notes_value.strip() |
|
1337 |
+ elif ( |
|
1338 |
+ modern_editor_interface and notes_value.strip() != text.strip() |
|
1339 |
+ ): |
|
1340 |
+ notes_lines = collections.deque( |
|
1341 |
+ notes_value.splitlines(keepends=True) |
|
1121 | 1342 |
) |
1343 |
+ while notes_lines: |
|
1344 |
+ line = notes_lines.popleft() |
|
1345 |
+ if line.startswith(str(notes_marker)): |
|
1346 |
+ notes_value = ''.join(notes_lines) |
|
1347 |
+ break |
|
1122 | 1348 |
else: |
1123 |
- logger.warning( |
|
1349 |
+ if not notes_value.strip(): |
|
1350 |
+ self.err( |
|
1124 | 1351 |
_msg.TranslatedString( |
1125 |
- _msg.WarnMsgTemplate.STEP_REMOVE_INEFFECTIVE_VALUE, |
|
1126 |
- path=_types.json_path(step.path), |
|
1127 |
- old=json.dumps(step.old_value), |
|
1128 |
- ), |
|
1129 |
- extra={'color': ctx.color}, |
|
1352 |
+ _msg.ErrMsgTemplate.USER_ABORTED_EDIT |
|
1130 | 1353 |
) |
1131 |
- if '' in maybe_config['services']: |
|
1132 |
- logger.warning( |
|
1133 |
- _msg.TranslatedString( |
|
1134 |
- _msg.WarnMsgTemplate.EMPTY_SERVICE_SETTINGS_INACCESSIBLE, |
|
1135 |
- service_metavar=service_metavar, |
|
1136 |
- PROG_NAME=PROG_NAME, |
|
1137 |
- ), |
|
1138 |
- extra={'color': ctx.color}, |
|
1139 | 1354 |
) |
1140 |
- for service_name in sorted(maybe_config['services'].keys()): |
|
1141 |
- if not cli_helpers.is_completable_item(service_name): |
|
1142 |
- logger.warning( |
|
1143 |
- _msg.TranslatedString( |
|
1144 |
- _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
|
1145 |
- service=service_name, |
|
1146 |
- ), |
|
1147 |
- extra={'color': ctx.color}, |
|
1355 |
+ subtree['notes'] = notes_value.strip() |
|
1356 |
+ self.put_config(configuration) |
|
1357 |
+ |
|
1358 |
+ def run_op_derive_passphrase(self) -> None: |
|
1359 |
+ """Derive a passphrase. |
|
1360 |
+ |
|
1361 |
+ Derive a service passphrase using the effective settings from |
|
1362 |
+ both the command-line and the stored configuration. If any |
|
1363 |
+ service notes are stored for this service, print them as well. |
|
1364 |
+ |
|
1365 |
+ (An empty service name is permitted, though discouraged for |
|
1366 |
+ compatibility reasons.) |
|
1367 |
+ |
|
1368 |
+ Issue a warning (if appropriate) for Unicode normalization |
|
1369 |
+ issues with the interactive passphrase. Respect the |
|
1370 |
+ `--print-notes-before` and `--print-notes-after` options when |
|
1371 |
+ printing notes. |
|
1372 |
+ |
|
1373 |
+ Raises: |
|
1374 |
+ click.UsageError: |
|
1375 |
+ No master passphrase or master SSH key was given on both |
|
1376 |
+ the command-line and in the vault configuration on disk. |
|
1377 |
+ """ |
|
1378 |
+ service = self.ctx.params['service'] |
|
1379 |
+ use_key = self.ctx.params['use_key'] |
|
1380 |
+ use_phrase = self.ctx.params['use_phrase'] |
|
1381 |
+ print_notes_before = self.ctx.params['print_notes_before'] |
|
1382 |
+ user_config = self.get_user_config() |
|
1383 |
+ settings = self.run_subop_query_phrase_or_key_change( |
|
1384 |
+ empty_service_permitted=False |
|
1148 | 1385 |
) |
1386 |
+ if use_phrase: |
|
1149 | 1387 |
try: |
1150 | 1388 |
cli_helpers.check_for_misleading_passphrase( |
1151 |
- ('global',), |
|
1152 |
- cast('dict[str, Any]', maybe_config.get('global', {})), |
|
1153 |
- main_config=user_config, |
|
1154 |
- ctx=ctx, |
|
1155 |
- ) |
|
1156 |
- for key, value in maybe_config['services'].items(): |
|
1157 |
- cli_helpers.check_for_misleading_passphrase( |
|
1158 |
- ('services', key), |
|
1159 |
- cast('dict[str, Any]', value), |
|
1389 |
+ cli_helpers.ORIGIN.INTERACTIVE, |
|
1390 |
+ {'phrase': settings['phrase']}, |
|
1160 | 1391 |
main_config=user_config, |
1161 |
- ctx=ctx, |
|
1392 |
+ ctx=self.ctx, |
|
1162 | 1393 |
) |
1163 | 1394 |
except AssertionError as exc: |
1164 |
- err( |
|
1395 |
+ self.err( |
|
1165 | 1396 |
_msg.TranslatedString( |
1166 | 1397 |
_msg.ErrMsgTemplate.INVALID_USER_CONFIG, |
1167 | 1398 |
error=exc, |
1168 | 1399 |
filename=None, |
1169 | 1400 |
).maybe_without_filename(), |
1170 | 1401 |
) |
1171 |
- global_obj = maybe_config.get('global', {}) |
|
1172 |
- has_key = _types.js_truthiness(global_obj.get('key')) |
|
1173 |
- has_phrase = _types.js_truthiness(global_obj.get('phrase')) |
|
1174 |
- if has_key and has_phrase: |
|
1175 |
- logger.warning( |
|
1176 |
- _msg.TranslatedString( |
|
1177 |
- _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE, |
|
1178 |
- ), |
|
1179 |
- extra={'color': ctx.color}, |
|
1402 |
+ phrase: str | bytes |
|
1403 |
+ overrides = cast('dict[str, int | str]', settings.maps[0]) |
|
1404 |
+ # If either --key or --phrase are given, use that setting. |
|
1405 |
+ # Otherwise, if both key and phrase are set in the config, |
|
1406 |
+ # use the key. Otherwise, if only one of key and phrase is |
|
1407 |
+ # set in the config, use that one. In all these above |
|
1408 |
+ # cases, set the phrase via vault.Vault.phrase_from_key if |
|
1409 |
+ # a key is given. Finally, if nothing is set, error out. |
|
1410 |
+ if use_key: |
|
1411 |
+ phrase = cli_helpers.key_to_phrase( |
|
1412 |
+ cast('str', overrides['key']), error_callback=self.err |
|
1180 | 1413 |
) |
1181 |
- for service_name, service_obj in maybe_config['services'].items(): |
|
1182 |
- has_key = _types.js_truthiness( |
|
1183 |
- service_obj.get('key') |
|
1184 |
- ) or _types.js_truthiness(global_obj.get('key')) |
|
1185 |
- has_phrase = _types.js_truthiness( |
|
1186 |
- service_obj.get('phrase') |
|
1187 |
- ) or _types.js_truthiness(global_obj.get('phrase')) |
|
1188 |
- if has_key and has_phrase: |
|
1189 |
- logger.warning( |
|
1190 |
- _msg.TranslatedString( |
|
1191 |
- _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, |
|
1192 |
- service=json.dumps(service_name), |
|
1193 |
- ), |
|
1194 |
- extra={'color': ctx.color}, |
|
1414 |
+ elif use_phrase: |
|
1415 |
+ phrase = cast('str', overrides['phrase']) |
|
1416 |
+ elif settings.get('key'): |
|
1417 |
+ phrase = cli_helpers.key_to_phrase( |
|
1418 |
+ cast('str', settings['key']), error_callback=self.err |
|
1195 | 1419 |
) |
1196 |
- if overwrite_config: |
|
1197 |
- put_config(maybe_config) |
|
1420 |
+ elif settings.get('phrase'): |
|
1421 |
+ phrase = cast('str', settings['phrase']) |
|
1198 | 1422 |
else: |
1199 |
- configuration = get_config() |
|
1200 |
- merged_config: collections.ChainMap[str, Any] = ( |
|
1201 |
- collections.ChainMap( |
|
1202 |
- { |
|
1203 |
- 'services': collections.ChainMap( |
|
1204 |
- maybe_config['services'], |
|
1205 |
- configuration['services'], |
|
1206 |
- ), |
|
1207 |
- }, |
|
1208 |
- {'global': maybe_config['global']} |
|
1209 |
- if 'global' in maybe_config |
|
1210 |
- else {}, |
|
1211 |
- {'global': configuration['global']} |
|
1212 |
- if 'global' in configuration |
|
1213 |
- else {}, |
|
1214 |
- ) |
|
1423 |
+ err_msg = _msg.TranslatedString( |
|
1424 |
+ _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE |
|
1215 | 1425 |
) |
1216 |
- new_config: Any = { |
|
1217 |
- k: dict(v) if isinstance(v, collections.ChainMap) else v |
|
1218 |
- for k, v in sorted(merged_config.items()) |
|
1219 |
- } |
|
1220 |
- assert _types.is_vault_config(new_config) |
|
1221 |
- put_config(new_config) |
|
1222 |
- elif export_settings: |
|
1223 |
- configuration = get_config() |
|
1224 |
- try: |
|
1225 |
- # TODO(the-13th-letter): keep track of auto-close; try |
|
1226 |
- # os.dup if feasible |
|
1227 |
- outfile = cast( |
|
1228 |
- 'TextIO', |
|
1229 |
- ( |
|
1230 |
- export_settings |
|
1231 |
- if hasattr(export_settings, 'close') |
|
1232 |
- else click.open_file(os.fspath(export_settings), 'wt') |
|
1233 |
- ), |
|
1234 |
- ) |
|
1235 |
- # Don't specifically catch TypeError or ValueError here if |
|
1236 |
- # the passed-in fileobj is not a writable text stream. This |
|
1237 |
- # will never happen on the command-line (thanks to `click`), |
|
1238 |
- # and for programmatic use, our caller may want accurate |
|
1239 |
- # error information. |
|
1240 |
- with outfile: |
|
1241 |
- if export_as == 'sh': |
|
1242 |
- this_ctx = ctx |
|
1243 |
- prog_name_pieces = collections.deque([ |
|
1244 |
- this_ctx.info_name or 'vault', |
|
1245 |
- ]) |
|
1246 |
- while ( |
|
1247 |
- this_ctx.parent is not None |
|
1248 |
- and this_ctx.parent.info_name is not None |
|
1249 |
- ): |
|
1250 |
- prog_name_pieces.appendleft( |
|
1251 |
- this_ctx.parent.info_name |
|
1252 |
- ) |
|
1253 |
- this_ctx = this_ctx.parent |
|
1254 |
- cli_helpers.print_config_as_sh_script( |
|
1255 |
- configuration, |
|
1256 |
- outfile=outfile, |
|
1257 |
- prog_name_list=prog_name_pieces, |
|
1258 |
- ) |
|
1259 |
- else: |
|
1260 |
- json.dump( |
|
1261 |
- configuration, |
|
1262 |
- outfile, |
|
1263 |
- ensure_ascii=False, |
|
1264 |
- indent=2, |
|
1265 |
- sort_keys=True, |
|
1266 |
- ) |
|
1267 |
- except OSError as exc: |
|
1268 |
- err( |
|
1269 |
- _msg.TranslatedString( |
|
1270 |
- _msg.ErrMsgTemplate.CANNOT_EXPORT_VAULT_SETTINGS, |
|
1271 |
- error=exc.strerror, |
|
1272 |
- filename=exc.filename, |
|
1273 |
- ).maybe_without_filename(), |
|
1274 |
- ) |
|
1275 |
- else: |
|
1276 |
- configuration = get_config() |
|
1277 |
- # This block could be type checked more stringently, but this |
|
1278 |
- # would probably involve a lot of code repetition. Since we |
|
1279 |
- # have a type guarding function anyway, assert that we didn't |
|
1280 |
- # make any mistakes at the end instead. |
|
1281 |
- global_keys = {'key', 'phrase'} |
|
1282 |
- service_keys = { |
|
1283 |
- 'key', |
|
1284 |
- 'phrase', |
|
1426 |
+ raise click.UsageError(str(err_msg)) |
|
1427 |
+ overrides.pop('key', '') |
|
1428 |
+ overrides.pop('phrase', '') |
|
1429 |
+ assert service is not None |
|
1430 |
+ vault_service_keys = { |
|
1285 | 1431 |
'length', |
1286 | 1432 |
'repeat', |
1287 | 1433 |
'lower', |
... | ... |
@@ -1291,283 +1437,442 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
1291 | 1437 |
'dash', |
1292 | 1438 |
'symbol', |
1293 | 1439 |
} |
1294 |
- settings: collections.ChainMap[str, Any] = collections.ChainMap( |
|
1295 |
- { |
|
1296 |
- k: v |
|
1297 |
- for k, v in locals().items() |
|
1298 |
- if k in service_keys and v is not None |
|
1299 |
- }, |
|
1300 |
- cast( |
|
1301 |
- 'dict[str, Any]', |
|
1302 |
- configuration['services'].get(service, {}) |
|
1303 |
- if service |
|
1304 |
- else {}, |
|
1305 |
- ), |
|
1306 |
- cast('dict[str, Any]', configuration.get('global', {})), |
|
1307 |
- ) |
|
1308 |
- if not store_config_only and not service: |
|
1309 |
- err_msg = _msg.TranslatedString( |
|
1310 |
- _msg.ErrMsgTemplate.SERVICE_REQUIRED, |
|
1440 |
+ kwargs = { |
|
1441 |
+ cast('str', k): cast('int', settings[k]) |
|
1442 |
+ for k in vault_service_keys |
|
1443 |
+ if k in settings |
|
1444 |
+ } |
|
1445 |
+ result = vault.Vault(phrase=phrase, **kwargs).generate(service) |
|
1446 |
+ service_notes = cast('str', settings.get('notes', '')).strip() |
|
1447 |
+ if print_notes_before and service_notes.strip(): |
|
1448 |
+ click.echo(f'{service_notes}\n', err=True, color=self.ctx.color) |
|
1449 |
+ click.echo(result.decode('ASCII'), color=self.ctx.color) |
|
1450 |
+ if not print_notes_before and service_notes.strip(): |
|
1451 |
+ click.echo(f'\n{service_notes}\n', err=True, color=self.ctx.color) |
|
1452 |
+ |
|
1453 |
+ |
|
1454 |
+@derivepassphrase.command( |
|
1455 |
+ 'vault', |
|
1456 |
+ context_settings={'help_option_names': ['-h', '--help']}, |
|
1457 |
+ cls=cli_machinery.CommandWithHelpGroups, |
|
1458 |
+ help=( |
|
1459 |
+ _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01), |
|
1460 |
+ _msg.TranslatedString( |
|
1461 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_02, |
|
1311 | 1462 |
service_metavar=_msg.TranslatedString( |
1312 | 1463 |
_msg.Label.VAULT_METAVAR_SERVICE |
1313 | 1464 |
), |
1465 |
+ ), |
|
1466 |
+ ), |
|
1467 |
+ epilog=( |
|
1468 |
+ _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_01), |
|
1469 |
+ _msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_EPILOG_02), |
|
1470 |
+ ), |
|
1314 | 1471 |
) |
1315 |
- raise click.UsageError(str(err_msg)) |
|
1316 |
- if use_key: |
|
1317 |
- try: |
|
1318 |
- key = base64.standard_b64encode( |
|
1319 |
- cli_helpers.select_ssh_key(ctx=ctx) |
|
1320 |
- ).decode('ASCII') |
|
1321 |
- except IndexError: |
|
1322 |
- err( |
|
1323 |
- _msg.TranslatedString( |
|
1324 |
- _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION |
|
1472 |
+@click.option( |
|
1473 |
+ '-p', |
|
1474 |
+ '--phrase', |
|
1475 |
+ 'use_phrase', |
|
1476 |
+ is_flag=True, |
|
1477 |
+ help=_msg.TranslatedString( |
|
1478 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT |
|
1325 | 1479 |
), |
1480 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1326 | 1481 |
) |
1327 |
- except KeyError: |
|
1328 |
- err( |
|
1329 |
- _msg.TranslatedString( |
|
1330 |
- _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND |
|
1482 |
+@click.option( |
|
1483 |
+ '-k', |
|
1484 |
+ '--key', |
|
1485 |
+ 'use_key', |
|
1486 |
+ is_flag=True, |
|
1487 |
+ help=_msg.TranslatedString( |
|
1488 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT |
|
1331 | 1489 |
), |
1490 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1332 | 1491 |
) |
1333 |
- except LookupError: |
|
1334 |
- err( |
|
1335 |
- _msg.TranslatedString( |
|
1336 |
- _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS, |
|
1337 |
- PROG_NAME=PROG_NAME, |
|
1492 |
+@click.option( |
|
1493 |
+ '-l', |
|
1494 |
+ '--length', |
|
1495 |
+ metavar=_msg.TranslatedString( |
|
1496 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1497 |
+ ), |
|
1498 |
+ callback=cli_machinery.validate_length, |
|
1499 |
+ help=_msg.TranslatedString( |
|
1500 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT, |
|
1501 |
+ metavar=_msg.TranslatedString( |
|
1502 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1503 |
+ ), |
|
1504 |
+ ), |
|
1505 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1338 | 1506 |
) |
1507 |
+@click.option( |
|
1508 |
+ '-r', |
|
1509 |
+ '--repeat', |
|
1510 |
+ metavar=_msg.TranslatedString( |
|
1511 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1512 |
+ ), |
|
1513 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1514 |
+ help=_msg.TranslatedString( |
|
1515 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT, |
|
1516 |
+ metavar=_msg.TranslatedString( |
|
1517 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1518 |
+ ), |
|
1519 |
+ ), |
|
1520 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1339 | 1521 |
) |
1340 |
- except NotImplementedError: |
|
1341 |
- err(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX)) |
|
1342 |
- except OSError as exc: |
|
1343 |
- err( |
|
1344 |
- _msg.TranslatedString( |
|
1345 |
- _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT, |
|
1346 |
- error=exc.strerror, |
|
1347 |
- filename=exc.filename, |
|
1348 |
- ).maybe_without_filename(), |
|
1522 |
+@click.option( |
|
1523 |
+ '--lower', |
|
1524 |
+ metavar=_msg.TranslatedString( |
|
1525 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1526 |
+ ), |
|
1527 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1528 |
+ help=_msg.TranslatedString( |
|
1529 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT, |
|
1530 |
+ metavar=_msg.TranslatedString( |
|
1531 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1532 |
+ ), |
|
1533 |
+ ), |
|
1534 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1349 | 1535 |
) |
1350 |
- except ssh_agent.SSHAgentFailedError as exc: |
|
1351 |
- err( |
|
1352 |
- _msg.TranslatedString( |
|
1353 |
- _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS |
|
1536 |
+@click.option( |
|
1537 |
+ '--upper', |
|
1538 |
+ metavar=_msg.TranslatedString( |
|
1539 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1354 | 1540 |
), |
1355 |
- exc_info=exc, |
|
1541 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1542 |
+ help=_msg.TranslatedString( |
|
1543 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT, |
|
1544 |
+ metavar=_msg.TranslatedString( |
|
1545 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1546 |
+ ), |
|
1547 |
+ ), |
|
1548 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1356 | 1549 |
) |
1357 |
- except RuntimeError as exc: |
|
1358 |
- err( |
|
1359 |
- _msg.TranslatedString( |
|
1360 |
- _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT |
|
1550 |
+@click.option( |
|
1551 |
+ '--number', |
|
1552 |
+ metavar=_msg.TranslatedString( |
|
1553 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1361 | 1554 |
), |
1362 |
- exc_info=exc, |
|
1555 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1556 |
+ help=_msg.TranslatedString( |
|
1557 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT, |
|
1558 |
+ metavar=_msg.TranslatedString( |
|
1559 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1560 |
+ ), |
|
1561 |
+ ), |
|
1562 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1363 | 1563 |
) |
1364 |
- elif use_phrase: |
|
1365 |
- maybe_phrase = cli_helpers.prompt_for_passphrase() |
|
1366 |
- if not maybe_phrase: |
|
1367 |
- err( |
|
1368 |
- _msg.TranslatedString( |
|
1369 |
- _msg.ErrMsgTemplate.USER_ABORTED_PASSPHRASE |
|
1564 |
+@click.option( |
|
1565 |
+ '--space', |
|
1566 |
+ metavar=_msg.TranslatedString( |
|
1567 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1568 |
+ ), |
|
1569 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1570 |
+ help=_msg.TranslatedString( |
|
1571 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT, |
|
1572 |
+ metavar=_msg.TranslatedString( |
|
1573 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1574 |
+ ), |
|
1575 |
+ ), |
|
1576 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1370 | 1577 |
) |
1578 |
+@click.option( |
|
1579 |
+ '--dash', |
|
1580 |
+ metavar=_msg.TranslatedString( |
|
1581 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1582 |
+ ), |
|
1583 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1584 |
+ help=_msg.TranslatedString( |
|
1585 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT, |
|
1586 |
+ metavar=_msg.TranslatedString( |
|
1587 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1588 |
+ ), |
|
1589 |
+ ), |
|
1590 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1371 | 1591 |
) |
1372 |
- else: |
|
1373 |
- phrase = maybe_phrase |
|
1374 |
- if store_config_only: |
|
1375 |
- view: collections.ChainMap[str, Any] |
|
1376 |
- view = ( |
|
1377 |
- collections.ChainMap(*settings.maps[:2]) |
|
1378 |
- if service |
|
1379 |
- else collections.ChainMap( |
|
1380 |
- settings.maps[0], settings.maps[2] |
|
1592 |
+@click.option( |
|
1593 |
+ '--symbol', |
|
1594 |
+ metavar=_msg.TranslatedString( |
|
1595 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1596 |
+ ), |
|
1597 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
1598 |
+ help=_msg.TranslatedString( |
|
1599 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT, |
|
1600 |
+ metavar=_msg.TranslatedString( |
|
1601 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1602 |
+ ), |
|
1603 |
+ ), |
|
1604 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
1381 | 1605 |
) |
1606 |
+@click.option( |
|
1607 |
+ '-n', |
|
1608 |
+ '--notes', |
|
1609 |
+ 'edit_notes', |
|
1610 |
+ is_flag=True, |
|
1611 |
+ help=_msg.TranslatedString( |
|
1612 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_HELP_TEXT, |
|
1613 |
+ service_metavar=_msg.TranslatedString( |
|
1614 |
+ _msg.Label.VAULT_METAVAR_SERVICE |
|
1615 |
+ ), |
|
1616 |
+ ), |
|
1617 |
+ cls=cli_machinery.ConfigurationOption, |
|
1382 | 1618 |
) |
1383 |
- if use_key: |
|
1384 |
- view['key'] = key |
|
1385 |
- elif use_phrase: |
|
1386 |
- view['phrase'] = phrase |
|
1387 |
- try: |
|
1388 |
- cli_helpers.check_for_misleading_passphrase( |
|
1389 |
- ('services', service) if service else ('global',), |
|
1390 |
- {'phrase': phrase}, |
|
1391 |
- main_config=user_config, |
|
1392 |
- ctx=ctx, |
|
1393 |
- ) |
|
1394 |
- except AssertionError as exc: |
|
1395 |
- err( |
|
1396 |
- _msg.TranslatedString( |
|
1397 |
- _msg.ErrMsgTemplate.INVALID_USER_CONFIG, |
|
1398 |
- error=exc, |
|
1399 |
- filename=None, |
|
1400 |
- ).maybe_without_filename(), |
|
1401 |
- ) |
|
1402 |
- if 'key' in settings: |
|
1403 |
- if service: |
|
1404 |
- w_msg = _msg.TranslatedString( |
|
1405 |
- _msg.WarnMsgTemplate.SERVICE_PASSPHRASE_INEFFECTIVE, |
|
1406 |
- service=json.dumps(service), |
|
1407 |
- ) |
|
1408 |
- else: |
|
1409 |
- w_msg = _msg.TranslatedString( |
|
1410 |
- _msg.WarnMsgTemplate.GLOBAL_PASSPHRASE_INEFFECTIVE |
|
1411 |
- ) |
|
1412 |
- logger.warning(w_msg, extra={'color': ctx.color}) |
|
1413 |
- if not view.maps[0] and not unset_settings and not edit_notes: |
|
1414 |
- err_msg = _msg.TranslatedString( |
|
1415 |
- _msg.ErrMsgTemplate.CANNOT_UPDATE_SETTINGS_NO_SETTINGS, |
|
1416 |
- settings_type=_msg.TranslatedString( |
|
1417 |
- _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_SERVICE |
|
1418 |
- if service |
|
1419 |
- else _msg.Label.CANNOT_UPDATE_SETTINGS_METAVAR_SETTINGS_TYPE_GLOBAL # noqa: E501 |
|
1619 |
+@click.option( |
|
1620 |
+ '-c', |
|
1621 |
+ '--config', |
|
1622 |
+ 'store_config_only', |
|
1623 |
+ is_flag=True, |
|
1624 |
+ help=_msg.TranslatedString( |
|
1625 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_CONFIG_HELP_TEXT, |
|
1626 |
+ service_metavar=_msg.TranslatedString( |
|
1627 |
+ _msg.Label.VAULT_METAVAR_SERVICE |
|
1420 | 1628 |
), |
1421 |
- ) |
|
1422 |
- raise click.UsageError(str(err_msg)) |
|
1423 |
- for setting in unset_settings: |
|
1424 |
- if setting in view.maps[0]: |
|
1425 |
- err_msg = _msg.TranslatedString( |
|
1426 |
- _msg.ErrMsgTemplate.SET_AND_UNSET_SAME_SETTING, |
|
1427 |
- setting=setting, |
|
1428 |
- ) |
|
1429 |
- raise click.UsageError(str(err_msg)) |
|
1430 |
- if not cli_helpers.is_completable_item(service): |
|
1431 |
- logger.warning( |
|
1432 |
- _msg.TranslatedString( |
|
1433 |
- _msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
|
1434 |
- service=service, |
|
1435 | 1629 |
), |
1436 |
- extra={'color': ctx.color}, |
|
1437 |
- ) |
|
1438 |
- subtree: dict[str, Any] = ( |
|
1439 |
- configuration['services'].setdefault(service, {}) # type: ignore[assignment] |
|
1440 |
- if service |
|
1441 |
- else configuration.setdefault('global', {}) |
|
1442 |
- ) |
|
1443 |
- if overwrite_config: |
|
1444 |
- subtree.clear() |
|
1445 |
- else: |
|
1446 |
- for setting in unset_settings: |
|
1447 |
- subtree.pop(setting, None) |
|
1448 |
- subtree.update(view) |
|
1449 |
- assert _types.is_vault_config(configuration), ( |
|
1450 |
- f'Invalid vault configuration: {configuration!r}' |
|
1451 |
- ) |
|
1452 |
- if edit_notes: |
|
1453 |
- assert service is not None |
|
1454 |
- notes_instructions = _msg.TranslatedString( |
|
1455 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_INSTRUCTION_TEXT |
|
1456 |
- ) |
|
1457 |
- notes_marker = _msg.TranslatedString( |
|
1458 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER |
|
1459 |
- ) |
|
1460 |
- notes_legacy_instructions = _msg.TranslatedString( |
|
1461 |
- _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT |
|
1630 |
+ cls=cli_machinery.ConfigurationOption, |
|
1462 | 1631 |
) |
1463 |
- old_notes_value = subtree.get('notes', '') |
|
1464 |
- if modern_editor_interface: |
|
1465 |
- text = '\n'.join([ |
|
1466 |
- str(notes_instructions), |
|
1467 |
- str(notes_marker), |
|
1468 |
- old_notes_value, |
|
1469 |
- ]) |
|
1470 |
- else: |
|
1471 |
- text = old_notes_value or str( |
|
1472 |
- notes_legacy_instructions |
|
1632 |
+@click.option( |
|
1633 |
+ '-x', |
|
1634 |
+ '--delete', |
|
1635 |
+ 'delete_service_settings', |
|
1636 |
+ is_flag=True, |
|
1637 |
+ help=_msg.TranslatedString( |
|
1638 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_HELP_TEXT, |
|
1639 |
+ service_metavar=_msg.TranslatedString( |
|
1640 |
+ _msg.Label.VAULT_METAVAR_SERVICE |
|
1641 |
+ ), |
|
1642 |
+ ), |
|
1643 |
+ cls=cli_machinery.ConfigurationOption, |
|
1473 | 1644 |
) |
1474 |
- notes_value = click.edit(text=text, require_save=False) |
|
1475 |
- assert notes_value is not None |
|
1476 |
- if ( |
|
1477 |
- not modern_editor_interface |
|
1478 |
- and notes_value.strip() != old_notes_value.strip() |
|
1479 |
- ): |
|
1480 |
- backup_file = cli_helpers.config_filename( |
|
1481 |
- subsystem='notes backup' |
|
1645 |
+@click.option( |
|
1646 |
+ '--delete-globals', |
|
1647 |
+ is_flag=True, |
|
1648 |
+ help=_msg.TranslatedString( |
|
1649 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT, |
|
1650 |
+ ), |
|
1651 |
+ cls=cli_machinery.ConfigurationOption, |
|
1482 | 1652 |
) |
1483 |
- backup_file.write_text( |
|
1484 |
- old_notes_value, encoding='UTF-8' |
|
1653 |
+@click.option( |
|
1654 |
+ '-X', |
|
1655 |
+ '--clear', |
|
1656 |
+ 'clear_all_settings', |
|
1657 |
+ is_flag=True, |
|
1658 |
+ help=_msg.TranslatedString( |
|
1659 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT, |
|
1660 |
+ ), |
|
1661 |
+ cls=cli_machinery.ConfigurationOption, |
|
1485 | 1662 |
) |
1486 |
- logger.warning( |
|
1487 |
- _msg.TranslatedString( |
|
1488 |
- _msg.WarnMsgTemplate.LEGACY_EDITOR_INTERFACE_NOTES_BACKUP, |
|
1489 |
- filename=str(backup_file), |
|
1663 |
+@click.option( |
|
1664 |
+ '-e', |
|
1665 |
+ '--export', |
|
1666 |
+ 'export_settings', |
|
1667 |
+ metavar=_msg.TranslatedString( |
|
1668 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1490 | 1669 |
), |
1491 |
- extra={'color': ctx.color}, |
|
1670 |
+ help=_msg.TranslatedString( |
|
1671 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_HELP_TEXT, |
|
1672 |
+ metavar=_msg.TranslatedString( |
|
1673 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1674 |
+ ), |
|
1675 |
+ ), |
|
1676 |
+ cls=cli_machinery.StorageManagementOption, |
|
1677 |
+ shell_complete=cli_helpers.shell_complete_path, |
|
1492 | 1678 |
) |
1493 |
- subtree['notes'] = notes_value.strip() |
|
1494 |
- elif ( |
|
1495 |
- modern_editor_interface |
|
1496 |
- and notes_value.strip() != text.strip() |
|
1497 |
- ): |
|
1498 |
- notes_lines = collections.deque( |
|
1499 |
- notes_value.splitlines(True) # noqa: FBT003 |
|
1679 |
+@click.option( |
|
1680 |
+ '-i', |
|
1681 |
+ '--import', |
|
1682 |
+ 'import_settings', |
|
1683 |
+ metavar=_msg.TranslatedString( |
|
1684 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1685 |
+ ), |
|
1686 |
+ help=_msg.TranslatedString( |
|
1687 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_IMPORT_HELP_TEXT, |
|
1688 |
+ metavar=_msg.TranslatedString( |
|
1689 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1690 |
+ ), |
|
1691 |
+ ), |
|
1692 |
+ cls=cli_machinery.StorageManagementOption, |
|
1693 |
+ shell_complete=cli_helpers.shell_complete_path, |
|
1500 | 1694 |
) |
1501 |
- while notes_lines: |
|
1502 |
- line = notes_lines.popleft() |
|
1503 |
- if line.startswith(str(notes_marker)): |
|
1504 |
- notes_value = ''.join(notes_lines) |
|
1505 |
- break |
|
1506 |
- else: |
|
1507 |
- if not notes_value.strip(): |
|
1508 |
- err( |
|
1509 |
- _msg.TranslatedString( |
|
1510 |
- _msg.ErrMsgTemplate.USER_ABORTED_EDIT |
|
1695 |
+@click.option( |
|
1696 |
+ '--overwrite-existing/--merge-existing', |
|
1697 |
+ 'overwrite_config', |
|
1698 |
+ default=False, |
|
1699 |
+ help=_msg.TranslatedString( |
|
1700 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT |
|
1701 |
+ ), |
|
1702 |
+ cls=cli_machinery.CompatibilityOption, |
|
1511 | 1703 |
) |
1704 |
+@click.option( |
|
1705 |
+ '--unset', |
|
1706 |
+ 'unset_settings', |
|
1707 |
+ multiple=True, |
|
1708 |
+ type=click.Choice([ |
|
1709 |
+ 'phrase', |
|
1710 |
+ 'key', |
|
1711 |
+ 'length', |
|
1712 |
+ 'repeat', |
|
1713 |
+ 'lower', |
|
1714 |
+ 'upper', |
|
1715 |
+ 'number', |
|
1716 |
+ 'space', |
|
1717 |
+ 'dash', |
|
1718 |
+ 'symbol', |
|
1719 |
+ 'notes', |
|
1720 |
+ ]), |
|
1721 |
+ help=_msg.TranslatedString( |
|
1722 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT |
|
1723 |
+ ), |
|
1724 |
+ cls=cli_machinery.CompatibilityOption, |
|
1512 | 1725 |
) |
1513 |
- subtree['notes'] = notes_value.strip() |
|
1514 |
- put_config(configuration) |
|
1515 |
- else: |
|
1516 |
- assert service is not None |
|
1517 |
- kwargs: dict[str, Any] = { |
|
1518 |
- k: v |
|
1519 |
- for k, v in settings.items() |
|
1520 |
- if k in service_keys and v is not None |
|
1521 |
- } |
|
1522 |
- if use_phrase: |
|
1523 |
- try: |
|
1524 |
- cli_helpers.check_for_misleading_passphrase( |
|
1525 |
- cli_helpers.ORIGIN.INTERACTIVE, |
|
1526 |
- {'phrase': phrase}, |
|
1527 |
- main_config=user_config, |
|
1528 |
- ctx=ctx, |
|
1726 |
+@click.option( |
|
1727 |
+ '--export-as', |
|
1728 |
+ type=click.Choice(['json', 'sh']), |
|
1729 |
+ default='json', |
|
1730 |
+ help=_msg.TranslatedString( |
|
1731 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT |
|
1732 |
+ ), |
|
1733 |
+ cls=cli_machinery.CompatibilityOption, |
|
1529 | 1734 |
) |
1530 |
- except AssertionError as exc: |
|
1531 |
- err( |
|
1532 |
- _msg.TranslatedString( |
|
1533 |
- _msg.ErrMsgTemplate.INVALID_USER_CONFIG, |
|
1534 |
- error=exc, |
|
1535 |
- filename=None, |
|
1536 |
- ).maybe_without_filename(), |
|
1735 |
+@click.option( |
|
1736 |
+ '--modern-editor-interface/--vault-legacy-editor-interface', |
|
1737 |
+ 'modern_editor_interface', |
|
1738 |
+ default=False, |
|
1739 |
+ help=_msg.TranslatedString( |
|
1740 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_EDITOR_INTERFACE_HELP_TEXT |
|
1741 |
+ ), |
|
1742 |
+ cls=cli_machinery.CompatibilityOption, |
|
1537 | 1743 |
) |
1538 |
- # If either --key or --phrase are given, use that setting. |
|
1539 |
- # Otherwise, if both key and phrase are set in the config, |
|
1540 |
- # use the key. Otherwise, if only one of key and phrase is |
|
1541 |
- # set in the config, use that one. In all these above |
|
1542 |
- # cases, set the phrase via vault.Vault.phrase_from_key if |
|
1543 |
- # a key is given. Finally, if nothing is set, error out. |
|
1544 |
- if use_key or use_phrase: |
|
1545 |
- kwargs['phrase'] = ( |
|
1546 |
- cli_helpers.key_to_phrase(key, error_callback=err) |
|
1547 |
- if use_key |
|
1548 |
- else phrase |
|
1549 |
- ) |
|
1550 |
- elif kwargs.get('key'): |
|
1551 |
- kwargs['phrase'] = cli_helpers.key_to_phrase( |
|
1552 |
- kwargs['key'], error_callback=err |
|
1553 |
- ) |
|
1554 |
- elif kwargs.get('phrase'): |
|
1555 |
- pass |
|
1556 |
- else: |
|
1557 |
- err_msg = _msg.TranslatedString( |
|
1558 |
- _msg.ErrMsgTemplate.NO_KEY_OR_PHRASE |
|
1744 |
+@click.option( |
|
1745 |
+ '--print-notes-before/--print-notes-after', |
|
1746 |
+ 'print_notes_before', |
|
1747 |
+ default=False, |
|
1748 |
+ help=_msg.TranslatedString( |
|
1749 |
+ _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT |
|
1750 |
+ ), |
|
1751 |
+ cls=cli_machinery.CompatibilityOption, |
|
1559 | 1752 |
) |
1560 |
- raise click.UsageError(str(err_msg)) |
|
1561 |
- kwargs.pop('key', '') |
|
1562 |
- service_notes = settings.get('notes', '').strip() |
|
1563 |
- result = vault.Vault(**kwargs).generate(service) |
|
1564 |
- if print_notes_before and service_notes.strip(): |
|
1565 |
- click.echo(f'{service_notes}\n', err=True, color=ctx.color) |
|
1566 |
- click.echo(result.decode('ASCII'), color=ctx.color) |
|
1567 |
- if not print_notes_before and service_notes.strip(): |
|
1568 |
- click.echo( |
|
1569 |
- f'\n{service_notes}\n', err=True, color=ctx.color |
|
1753 |
+@cli_machinery.version_option(cli_machinery.vault_version_option_callback) |
|
1754 |
+@cli_machinery.color_forcing_pseudo_option |
|
1755 |
+@cli_machinery.standard_logging_options |
|
1756 |
+@click.argument( |
|
1757 |
+ 'service', |
|
1758 |
+ metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE), |
|
1759 |
+ required=False, |
|
1760 |
+ default=None, |
|
1761 |
+ shell_complete=cli_helpers.shell_complete_service, |
|
1570 | 1762 |
) |
1763 |
+@click.pass_context |
|
1764 |
+def derivepassphrase_vault( |
|
1765 |
+ ctx: click.Context, |
|
1766 |
+ /, |
|
1767 |
+ **_kwargs: Any, # noqa: ANN401 |
|
1768 |
+) -> None: |
|
1769 |
+ """Derive a passphrase using the vault(1) derivation scheme. |
|
1770 |
+ |
|
1771 |
+ This is a [`click`][CLICK]-powered command-line interface function, |
|
1772 |
+ and not intended for programmatic use. See the |
|
1773 |
+ derivepassphrase-vault(1) manpage for full documentation of the |
|
1774 |
+ interface. (See also [`click.testing.CliRunner`][] for controlled, |
|
1775 |
+ programmatic invocation.) |
|
1776 |
+ |
|
1777 |
+ [CLICK]: https://pypi.org/package/click/ |
|
1778 |
+ |
|
1779 |
+ Parameters: |
|
1780 |
+ ctx (click.Context): |
|
1781 |
+ The `click` context. |
|
1782 |
+ |
|
1783 |
+ Other Parameters: |
|
1784 |
+ service: |
|
1785 |
+ A service name. Required, unless operating on global |
|
1786 |
+ settings or importing/exporting settings. |
|
1787 |
+ use_phrase: |
|
1788 |
+ Command-line argument `-p`/`--phrase`. If given, query the |
|
1789 |
+ user for a passphrase instead of an SSH key. |
|
1790 |
+ use_key: |
|
1791 |
+ Command-line argument `-k`/`--key`. If given, query the |
|
1792 |
+ user for an SSH key instead of a passphrase. |
|
1793 |
+ length: |
|
1794 |
+ Command-line argument `-l`/`--length`. Override the default |
|
1795 |
+ length of the generated passphrase. |
|
1796 |
+ repeat: |
|
1797 |
+ Command-line argument `-r`/`--repeat`. Override the default |
|
1798 |
+ repetition limit if positive, or disable the repetition |
|
1799 |
+ limit if 0. |
|
1800 |
+ lower: |
|
1801 |
+ Command-line argument `--lower`. Require a given amount of |
|
1802 |
+ ASCII lowercase characters if positive, else forbid ASCII |
|
1803 |
+ lowercase characters if 0. |
|
1804 |
+ upper: |
|
1805 |
+ Command-line argument `--upper`. Same as `lower`, but for |
|
1806 |
+ ASCII uppercase characters. |
|
1807 |
+ number: |
|
1808 |
+ Command-line argument `--number`. Same as `lower`, but for |
|
1809 |
+ ASCII digits. |
|
1810 |
+ space: |
|
1811 |
+ Command-line argument `--space`. Same as `lower`, but for |
|
1812 |
+ the space character. |
|
1813 |
+ dash: |
|
1814 |
+ Command-line argument `--dash`. Same as `lower`, but for |
|
1815 |
+ the hyphen-minus and underscore characters. |
|
1816 |
+ symbol: |
|
1817 |
+ Command-line argument `--symbol`. Same as `lower`, but for |
|
1818 |
+ all other ASCII printable characters except lowercase |
|
1819 |
+ characters, uppercase characters, digits, space and |
|
1820 |
+ backquote. |
|
1821 |
+ edit_notes: |
|
1822 |
+ Command-line argument `-n`/`--notes`. If given, spawn an |
|
1823 |
+ editor to edit notes for `service`. |
|
1824 |
+ store_config_only: |
|
1825 |
+ Command-line argument `-c`/`--config`. If given, saves the |
|
1826 |
+ other given settings (`--key`, ..., `--symbol`) to the |
|
1827 |
+ configuration file, either specifically for `service` or as |
|
1828 |
+ global settings. |
|
1829 |
+ delete_service_settings: |
|
1830 |
+ Command-line argument `-x`/`--delete`. If given, removes |
|
1831 |
+ the settings for `service` from the configuration file. |
|
1832 |
+ delete_globals: |
|
1833 |
+ Command-line argument `--delete-globals`. If given, removes |
|
1834 |
+ the global settings from the configuration file. |
|
1835 |
+ clear_all_settings: |
|
1836 |
+ Command-line argument `-X`/`--clear`. If given, removes all |
|
1837 |
+ settings from the configuration file. |
|
1838 |
+ export_settings: |
|
1839 |
+ Command-line argument `-e`/`--export`. If a file object, |
|
1840 |
+ then it must be open for writing and accept `str` inputs. |
|
1841 |
+ Otherwise, a filename to open for writing. Using `-` for |
|
1842 |
+ standard output is supported. |
|
1843 |
+ import_settings: |
|
1844 |
+ Command-line argument `-i`/`--import`. If a file object, it |
|
1845 |
+ must be open for reading and yield `str` values. Otherwise, |
|
1846 |
+ a filename to open for reading. Using `-` for standard |
|
1847 |
+ input is supported. |
|
1848 |
+ overwrite_config: |
|
1849 |
+ Command-line arguments `--overwrite-existing` (True) and |
|
1850 |
+ `--merge-existing` (False). Controls whether config saving |
|
1851 |
+ and config importing overwrite existing configurations, or |
|
1852 |
+ merge them section-wise instead. |
|
1853 |
+ unset_settings: |
|
1854 |
+ Command-line argument `--unset`. If given together with |
|
1855 |
+ `--config`, unsets the specified settings (in addition to |
|
1856 |
+ any other changes requested). |
|
1857 |
+ export_as: |
|
1858 |
+ Command-line argument `--export-as`. If given together with |
|
1859 |
+ `--export`, selects the format to export the current |
|
1860 |
+ configuration as: JSON ("json", default) or POSIX sh ("sh"). |
|
1861 |
+ modern_editor_interface: |
|
1862 |
+ Command-line arguments `--modern-editor-interface` (True) |
|
1863 |
+ and `--vault-legacy-editor-interface` (False). Controls |
|
1864 |
+ whether editing notes uses a modern editor interface |
|
1865 |
+ (supporting comments and aborting) or a vault(1)-compatible |
|
1866 |
+ legacy editor interface (WYSIWYG notes contents). |
|
1867 |
+ print_notes_before: |
|
1868 |
+ Command-line arguments `--print-notes-before` (True) and |
|
1869 |
+ `--print-notes-after` (False). Controls whether the service |
|
1870 |
+ notes (if any) are printed before the passphrase, or after. |
|
1871 |
+ |
|
1872 |
+ """ |
|
1873 |
+ vault_context = _VaultContext(ctx) |
|
1874 |
+ vault_context.validate_command_line() |
|
1875 |
+ vault_context.dispatch_op() |
|
1571 | 1876 |
|
1572 | 1877 |
|
1573 | 1878 |
if __name__ == '__main__': |
... | ... |
@@ -4290,6 +4290,40 @@ class TestCLI: |
4290 | 4290 |
'expected error exit and known error message' |
4291 | 4291 |
) |
4292 | 4292 |
|
4293 |
+ def test_311_bad_user_config_is_a_directory( |
|
4294 |
+ self, |
|
4295 |
+ ) -> None: |
|
4296 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
4297 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
4298 |
+ # TODO(the-13th-letter): Rewrite using parenthesized |
|
4299 |
+ # with-statements. |
|
4300 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
4301 |
+ with contextlib.ExitStack() as stack: |
|
4302 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
4303 |
+ stack.enter_context( |
|
4304 |
+ tests.isolated_vault_config( |
|
4305 |
+ monkeypatch=monkeypatch, |
|
4306 |
+ runner=runner, |
|
4307 |
+ vault_config={'services': {}}, |
|
4308 |
+ main_config_str='', |
|
4309 |
+ ) |
|
4310 |
+ ) |
|
4311 |
+ user_config = cli_helpers.config_filename( |
|
4312 |
+ subsystem='user configuration' |
|
4313 |
+ ) |
|
4314 |
+ user_config.unlink() |
|
4315 |
+ user_config.mkdir(parents=True, exist_ok=True) |
|
4316 |
+ result_ = runner.invoke( |
|
4317 |
+ cli.derivepassphrase_vault, |
|
4318 |
+ ['--phrase', '--', DUMMY_SERVICE], |
|
4319 |
+ input=DUMMY_PASSPHRASE, |
|
4320 |
+ catch_exceptions=False, |
|
4321 |
+ ) |
|
4322 |
+ result = tests.ReadableResult.parse(result_) |
|
4323 |
+ assert result.error_exit(error='Cannot load user config:'), ( |
|
4324 |
+ 'expected error exit and known error message' |
|
4325 |
+ ) |
|
4326 |
+ |
|
4293 | 4327 |
def test_400_missing_af_unix_support( |
4294 | 4328 |
self, |
4295 | 4329 |
) -> None: |
4296 | 4330 |