Optionally support printing vault service notes before the passphrase
Marco Ricci

Marco Ricci commited on 2025-02-07 16:01:14
Zeige 5 geänderte Dateien mit 106 Einfügungen und 6 Löschungen.


When deriving a passphrase, if the service has any notes, vault(1)
prints the notes after the passphrase.  However, akin to source code
comments before the code in question, service notes may actually be
better placed above the derived passphrase instead, for some types of
notes.  Support such placement via a new command-line option
`--print-notes-before`; the default, vault(1)-compatible behavior can be
explicitly selected via `--print-notes-after`.
... ...
@@ -7,7 +7,7 @@ derivepassphrase-vault – derive a passphrase using the vault derivation scheme
7 7
 ## SYNOPSIS
8 8
 
9 9
 <pre>
10
-<code><b>derivepassphrase vault</b> [--phrase | --key] [--length <var>n</var>] [--repeat <var>n</var>] [--lower <var>n</var>] [--upper <var>n</var>] [--number <var>n</var>] [--space <var>n</var>] [--dash <var>n</var>] [--symbol <var>n</var>] <var>SERVICE</var></code>
10
+<code><b>derivepassphrase vault</b> [--phrase | --key] [--length <var>n</var>] [--repeat <var>n</var>] [--lower <var>n</var>] [--upper <var>n</var>] [--number <var>n</var>] [--space <var>n</var>] [--dash <var>n</var>] [--symbol <var>n</var>] [--print-notes-before | --print-notes-after] <var>SERVICE</var></code>
11 11
 <code><b>derivepassphrase vault</b> {--phrase | --key | … | --symbol <var>n</var>} … --config [--unset <var>setting</var> …] [--overwrite-existing | --merge-existing] [<var>SERVICE</var>]</code>
12 12
 <code><b>derivepassphrase vault</b> [--phrase | --key | … | --symbol <var>n</var>] … --config --notes [--unset <var>setting</var> …] [--overwrite-existing | --merge-existing] [--modern-editor-interface | --vault-legacy-editor-interface] <var>SERVICE</var></code>
13 13
 <code><b>derivepassphrase vault</b> {--delete <var>SERVICE</var> | --delete-globals | --clear}</code>
... ...
@@ -174,6 +174,11 @@ The compatibility and extension options modify the behavior to enable additional
174 174
 
175 175
     (vault(1) behaves as if `--vault-legacy-editor-interface` were always given.)
176 176
 
177
+<b>-</b><b>-print-notes-before</b> / <b>-</b><b>-print-notes-after</b>
178
+:   When deriving a passphrase, if the service has any service notes, print these notes before or after (<em>default</em>) the passphrase.
179
+
180
+    (<i>vault</i>(1) behaves as if `--print-notes-after` were always given.)
181
+
177 182
 ### Other Options
178 183
 
179 184
 <b>-</b><b>-debug</b>
... ...
@@ -20,6 +20,7 @@
20 20
 .Op Fl \-space Ar n
21 21
 .Op Fl \-dash Ar n
22 22
 .Op Fl \-symbol Ar n
23
+.Op Fl \-print\-notes\-before | Fl \-print\-notes\-after
23 24
 .Ar SERVICE
24 25
 .
25 26
 .Nm derivepassphrase vault
... ...
@@ -463,6 +464,18 @@ behaves as if
463 464
 .Fl \-vault\-legacy\-editor\-interface
464 465
 were always given.)
465 466
 .
467
+.It Fl \-print\-notes\-before No "" / "" Fl \-print\-notes\-after
468
+When deriving a passphrase, if the service has any service notes,
469
+print these notes before or after
470
+.Em ( default )
471
+the passphrase.
472
+.Pp
473
+.
474
+.Xr ( vault 1
475
+behaves as if
476
+.Fl \-print\-notes\-after
477
+were always given.)
478
+.
466 479
 .El
467 480
 .
468 481
 .Ss Other options
... ...
@@ -1112,6 +1112,16 @@ class Label(enum.Enum):
1112 1112
         'or the vault-like legacy one (default)',
1113 1113
     )
1114 1114
     """"""
1115
+    DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT = commented(
1116
+        'The corresponding option is displayed as '
1117
+        '"--print-notes-before / --print-notes-after", so you may want to '
1118
+        'hint that the default (after) is the second of those options.',
1119
+    )(
1120
+        'Label :: Help text :: One-line description',
1121
+        'print the service notes (if any) before or after (default) '
1122
+        'the existing configuration',
1123
+    )
1124
+    """"""
1115 1125
 
1116 1126
     EXPORT_VAULT_FORMAT_METAVAR_FMT = commented(
1117 1127
         '',
... ...
@@ -602,6 +602,15 @@ def derivepassphrase_export_vault(
602 602
     ),
603 603
     cls=cli_machinery.CompatibilityOption,
604 604
 )
605
+@click.option(
606
+    '--print-notes-before/--print-notes-after',
607
+    'print_notes_before',
608
+    default=False,
609
+    help=_msg.TranslatedString(
610
+        _msg.Label.DERIVEPASSPHRASE_VAULT_PRINT_NOTES_BEFORE_HELP_TEXT
611
+    ),
612
+    cls=cli_machinery.CompatibilityOption,
613
+)
605 614
 @cli_machinery.version_option
606 615
 @cli_machinery.color_forcing_pseudo_option
607 616
 @cli_machinery.standard_logging_options
... ...
@@ -639,6 +648,7 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
639 648
     unset_settings: Sequence[str] = (),
640 649
     export_as: Literal['json', 'sh'] = 'json',
641 650
     modern_editor_interface: bool = False,
651
+    print_notes_before: bool = False,
642 652
 ) -> None:
643 653
     """Derive a passphrase using the vault(1) derivation scheme.
644 654
 
... ...
@@ -738,6 +748,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
738 748
             whether editing notes uses a modern editor interface
739 749
             (supporting comments and aborting) or a vault(1)-compatible
740 750
             legacy editor interface (WYSIWYG notes contents).
751
+        print_notes_before:
752
+            Command-line arguments `--print-notes-before` (True) and
753
+            `--print-notes-after` (False).  Controls whether the service
754
+            notes (if any) are printed before the passphrase, or after.
741 755
 
742 756
     """  # noqa: DOC501
743 757
     logger = logging.getLogger(PROG_NAME)
... ...
@@ -1510,13 +1524,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1510 1524
                 )
1511 1525
                 raise click.UsageError(str(err_msg))
1512 1526
             kwargs.pop('key', '')
1513
-            service_notes = (
1514
-                f'\n{settings["notes"]}\n\n' if 'notes' in settings else ''
1515
-            )
1527
+            service_notes = settings.get('notes', '').strip()
1516 1528
             result = vault.Vault(**kwargs).generate(service)
1517
-            if service_notes.strip():
1518
-                click.echo(service_notes, err=True, color=ctx.color)
1529
+            if print_notes_before and service_notes.strip():
1530
+                click.echo(f'{service_notes}\n', err=True, color=ctx.color)
1519 1531
             click.echo(result.decode('ASCII'), color=ctx.color)
1532
+            if not print_notes_before and service_notes.strip():
1533
+                click.echo(f'\n{service_notes}\n', err=True, color=ctx.color)
1520 1534
 
1521 1535
 
1522 1536
 if __name__ == '__main__':
... ...
@@ -641,6 +641,8 @@ class Parametrize(types.SimpleNamespace):
641 641
                     '--export-as',
642 642
                     '--modern-editor-interface',
643 643
                     '--vault-legacy-editor-interface',
644
+                    '--print-notes-before',
645
+                    '--print-notes-after',
644 646
                 }),
645 647
                 id='derivepassphrase-vault',
646 648
             ),
... ...
@@ -2470,6 +2472,62 @@ class TestCLI:
2470 2472
             'expected error exit and known error message'
2471 2473
         )
2472 2474
 
2475
+    @pytest.mark.parametrize(
2476
+        ['notes_placement', 'placement_args'],
2477
+        [
2478
+            pytest.param('after', ['--print-notes-after'], id='after'),
2479
+            pytest.param('before', ['--print-notes-before'], id='before'),
2480
+        ],
2481
+    )
2482
+    @hypothesis.given(
2483
+        notes=strategies.text(
2484
+            strategies.characters(
2485
+                min_codepoint=32, max_codepoint=126, include_characters='\n'
2486
+            ),
2487
+            min_size=1,
2488
+            max_size=512,
2489
+        ).filter(str.strip),
2490
+    )
2491
+    def test_215_notes_placement(
2492
+        self,
2493
+        notes_placement: Literal['before', 'after'],
2494
+        placement_args: list[str],
2495
+        notes: str,
2496
+    ) -> None:
2497
+        maybe_notes = {'notes': notes.strip()} if notes.strip() else {}
2498
+        vault_config = {
2499
+            'global': {'phrase': DUMMY_PASSPHRASE},
2500
+            'services': {
2501
+                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
2502
+            },
2503
+        }
2504
+        result_phrase = DUMMY_RESULT_PASSPHRASE.decode('ascii')
2505
+        expected = (
2506
+            f'{notes}\n\n{result_phrase}\n'
2507
+            if notes_placement == 'before'
2508
+            else f'{result_phrase}\n\n{notes}\n\n'
2509
+        )
2510
+        runner = click.testing.CliRunner(mix_stderr=True)
2511
+        # TODO(the-13th-letter): Rewrite using parenthesized
2512
+        # with-statements.
2513
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2514
+        with contextlib.ExitStack() as stack:
2515
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2516
+            stack.enter_context(
2517
+                tests.isolated_vault_config(
2518
+                    monkeypatch=monkeypatch,
2519
+                    runner=runner,
2520
+                    vault_config=vault_config,
2521
+                )
2522
+            )
2523
+            result_ = runner.invoke(
2524
+                cli.derivepassphrase_vault,
2525
+                [*placement_args, '--', DUMMY_SERVICE],
2526
+                catch_exceptions=False,
2527
+            )
2528
+            result = tests.ReadableResult.parse(result_)
2529
+            assert result.clean_exit(output=expected), 'expected clean exit'
2530
+
2473 2531
     @pytest.mark.parametrize(
2474 2532
         'modern_editor_interface', [False, True], ids=['legacy', 'modern']
2475 2533
     )
2476 2534