Move exporter command-line interface into a separate module
Marco Ricci

Marco Ricci commited on 2024-08-31 13:32:33
Zeige 7 geänderte Dateien mit 415 Einfügungen und 410 Löschungen.


Also replicate this module structure in the tests, which allows us to
use `pytest.importorskip` to deal with missing `cryptography` support.
... ...
@@ -2,33 +2,18 @@
2 2
 #
3 3
 # SPDX-License-Identifier: MIT
4 4
 
5
-"""Command-line interface for derivepassphrase_export."""
5
+"""Foreign configuration exporter for derivepassphrase."""
6 6
 
7 7
 from __future__ import annotations
8 8
 
9
-import base64
10
-import importlib
11
-import json
12
-import logging
13 9
 import os
14
-from typing import TYPE_CHECKING, Any, Literal
15
-
16
-import click
17
-from typing_extensions import assert_never
18 10
 
19 11
 import derivepassphrase as dpp
20
-from derivepassphrase import _types
21
-
22
-if TYPE_CHECKING:
23
-    import types
24
-    from collections.abc import Sequence
25 12
 
26 13
 __author__ = dpp.__author__
27 14
 __version__ = dpp.__version__
28 15
 
29
-__all__ = ('derivepassphrase_export',)
30
-
31
-PROG_NAME = 'derivepassphrase_export'
16
+__all__ = ()
32 17
 
33 18
 
34 19
 def get_vault_key() -> bytes:
... ...
@@ -86,139 +71,3 @@ def get_vault_path() -> str | bytes | os.PathLike:
86 71
         msg = 'Cannot determine home directory'
87 72
         raise RuntimeError(msg)
88 73
     return result
89
-
90
-
91
-@click.command(
92
-    context_settings={'help_option_names': ['-h', '--help']},
93
-)
94
-@click.option(
95
-    '-f',
96
-    '--format',
97
-    'formats',
98
-    metavar='FMT',
99
-    multiple=True,
100
-    default=('v0.3', 'v0.2', 'storeroom'),
101
-    type=click.Choice(['v0.2', 'v0.3', 'storeroom']),
102
-    help='try the following storage formats, in order (default: v0.3, v0.2)',
103
-)
104
-@click.option(
105
-    '-k',
106
-    '--key',
107
-    metavar='K',
108
-    help=(
109
-        'use K as the storage master key '
110
-        '(default: check the `VAULT_KEY`, `LOGNAME`, `USER` or '
111
-        '`USERNAME` environment variables)'
112
-    ),
113
-)
114
-@click.argument('path', metavar='PATH', required=True)
115
-@click.pass_context
116
-def derivepassphrase_export(
117
-    ctx: click.Context,
118
-    /,
119
-    *,
120
-    path: str | bytes | os.PathLike[str],
121
-    formats: Sequence[Literal['v0.2', 'v0.3', 'storeroom']] = (),
122
-    key: str | bytes | None = None,
123
-) -> None:
124
-    """Export a vault-native configuration to standard output.
125
-
126
-    Read the vault-native configuration at PATH, extract all information
127
-    from it, and export the resulting configuration to standard output.
128
-    Depending on the configuration format, this may either be a file or
129
-    a directory.
130
-
131
-    If PATH is explicitly given as `VAULT_PATH`, then use the
132
-    `VAULT_PATH` environment variable to determine the correct path.
133
-    (Use `./VAULT_PATH` or similar to indicate a file/directory actually
134
-    named `VAULT_PATH`.)
135
-
136
-    """
137
-
138
-    def load_data(
139
-        fmt: Literal['v0.2', 'v0.3', 'storeroom'],
140
-        path: str | bytes | os.PathLike[str],
141
-        key: bytes,
142
-    ) -> Any:
143
-        contents: bytes
144
-        module: types.ModuleType
145
-        match fmt:
146
-            case 'v0.2':
147
-                module = importlib.import_module(
148
-                    'derivepassphrase.exporter.vault_v03_and_below'
149
-                )
150
-                if module.STUBBED:
151
-                    raise ModuleNotFoundError
152
-                with open(path, 'rb') as infile:
153
-                    contents = base64.standard_b64decode(infile.read())
154
-                return module.V02Reader(contents, key).run()
155
-            case 'v0.3':
156
-                module = importlib.import_module(
157
-                    'derivepassphrase.exporter.vault_v03_and_below'
158
-                )
159
-                if module.STUBBED:
160
-                    raise ModuleNotFoundError
161
-                with open(path, 'rb') as infile:
162
-                    contents = base64.standard_b64decode(infile.read())
163
-                return module.V03Reader(contents, key).run()
164
-            case 'storeroom':
165
-                module = importlib.import_module(
166
-                    'derivepassphrase.exporter.storeroom'
167
-                )
168
-                if module.STUBBED:
169
-                    raise ModuleNotFoundError
170
-                return module.export_storeroom_data(path, key)
171
-            case _:  # pragma: no cover
172
-                assert_never(fmt)
173
-
174
-    logging.basicConfig()
175
-    if path in {'VAULT_PATH', b'VAULT_PATH'}:
176
-        path = get_vault_path()
177
-    if key is None:
178
-        key = get_vault_key()
179
-    elif isinstance(key, str):  # pragma: no branch
180
-        key = key.encode('utf-8')
181
-    for fmt in formats:
182
-        try:
183
-            config = load_data(fmt, path, key)
184
-        except (
185
-            IsADirectoryError,
186
-            NotADirectoryError,
187
-            ValueError,
188
-            RuntimeError,
189
-        ):
190
-            logging.info('Cannot load as %s: %s', fmt, path)
191
-            continue
192
-        except OSError as exc:
193
-            click.echo(
194
-                (
195
-                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
196
-                    f'a valid config: {exc.strerror}: {exc.filename!r}'
197
-                ),
198
-                err=True,
199
-            )
200
-            ctx.exit(1)
201
-        except ModuleNotFoundError:
202
-            # TODO(the-13th-letter): Use backslash continuation.
203
-            # https://github.com/nedbat/coveragepy/issues/1836
204
-            msg = f"""
205
-{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
206
-{PROG_NAME}: INFO: pip users: see the "export" extra.
207
-""".lstrip('\n')
208
-            click.echo(msg, nl=False, err=True)
209
-            ctx.exit(1)
210
-        else:
211
-            if not _types.is_vault_config(config):
212
-                click.echo(
213
-                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
214
-                    err=True,
215
-                )
216
-                ctx.exit(1)
217
-            click.echo(json.dumps(config, indent=2, sort_keys=True))
218
-            break
219
-    else:
220
-        click.echo(
221
-            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
222
-            err=True,
223
-        )
224
-        ctx.exit(1)
... ...
@@ -0,0 +1,167 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Command-line interface for derivepassphrase_export."""
6
+
7
+from __future__ import annotations
8
+
9
+import base64
10
+import importlib
11
+import json
12
+import logging
13
+from typing import TYPE_CHECKING, Any, Literal
14
+
15
+import click
16
+from typing_extensions import assert_never
17
+
18
+import derivepassphrase as dpp
19
+from derivepassphrase import _types, exporter
20
+
21
+if TYPE_CHECKING:
22
+    import os
23
+    import types
24
+    from collections.abc import Sequence
25
+
26
+__author__ = dpp.__author__
27
+__version__ = dpp.__version__
28
+
29
+__all__ = ('derivepassphrase_export',)
30
+
31
+PROG_NAME = 'derivepassphrase_export'
32
+
33
+
34
+@click.command(
35
+    context_settings={'help_option_names': ['-h', '--help']},
36
+)
37
+@click.option(
38
+    '-f',
39
+    '--format',
40
+    'formats',
41
+    metavar='FMT',
42
+    multiple=True,
43
+    default=('v0.3', 'v0.2', 'storeroom'),
44
+    type=click.Choice(['v0.2', 'v0.3', 'storeroom']),
45
+    help='try the following storage formats, in order (default: v0.3, v0.2)',
46
+)
47
+@click.option(
48
+    '-k',
49
+    '--key',
50
+    metavar='K',
51
+    help=(
52
+        'use K as the storage master key '
53
+        '(default: check the `VAULT_KEY`, `LOGNAME`, `USER` or '
54
+        '`USERNAME` environment variables)'
55
+    ),
56
+)
57
+@click.argument('path', metavar='PATH', required=True)
58
+@click.pass_context
59
+def derivepassphrase_export(
60
+    ctx: click.Context,
61
+    /,
62
+    *,
63
+    path: str | bytes | os.PathLike[str],
64
+    formats: Sequence[Literal['v0.2', 'v0.3', 'storeroom']] = (),
65
+    key: str | bytes | None = None,
66
+) -> None:
67
+    """Export a vault-native configuration to standard output.
68
+
69
+    Read the vault-native configuration at PATH, extract all information
70
+    from it, and export the resulting configuration to standard output.
71
+    Depending on the configuration format, this may either be a file or
72
+    a directory.
73
+
74
+    If PATH is explicitly given as `VAULT_PATH`, then use the
75
+    `VAULT_PATH` environment variable to determine the correct path.
76
+    (Use `./VAULT_PATH` or similar to indicate a file/directory actually
77
+    named `VAULT_PATH`.)
78
+
79
+    """
80
+
81
+    def load_data(
82
+        fmt: Literal['v0.2', 'v0.3', 'storeroom'],
83
+        path: str | bytes | os.PathLike[str],
84
+        key: bytes,
85
+    ) -> Any:
86
+        contents: bytes
87
+        module: types.ModuleType
88
+        match fmt:
89
+            case 'v0.2':
90
+                module = importlib.import_module(
91
+                    'derivepassphrase.exporter.vault_v03_and_below'
92
+                )
93
+                if module.STUBBED:
94
+                    raise ModuleNotFoundError
95
+                with open(path, 'rb') as infile:
96
+                    contents = base64.standard_b64decode(infile.read())
97
+                return module.V02Reader(contents, key).run()
98
+            case 'v0.3':
99
+                module = importlib.import_module(
100
+                    'derivepassphrase.exporter.vault_v03_and_below'
101
+                )
102
+                if module.STUBBED:
103
+                    raise ModuleNotFoundError
104
+                with open(path, 'rb') as infile:
105
+                    contents = base64.standard_b64decode(infile.read())
106
+                return module.V03Reader(contents, key).run()
107
+            case 'storeroom':
108
+                module = importlib.import_module(
109
+                    'derivepassphrase.exporter.storeroom'
110
+                )
111
+                if module.STUBBED:
112
+                    raise ModuleNotFoundError
113
+                return module.export_storeroom_data(path, key)
114
+            case _:  # pragma: no cover
115
+                assert_never(fmt)
116
+
117
+    logging.basicConfig()
118
+    if path in {'VAULT_PATH', b'VAULT_PATH'}:
119
+        path = exporter.get_vault_path()
120
+    if key is None:
121
+        key = exporter.get_vault_key()
122
+    elif isinstance(key, str):  # pragma: no branch
123
+        key = key.encode('utf-8')
124
+    for fmt in formats:
125
+        try:
126
+            config = load_data(fmt, path, key)
127
+        except (
128
+            IsADirectoryError,
129
+            NotADirectoryError,
130
+            ValueError,
131
+            RuntimeError,
132
+        ):
133
+            logging.info('Cannot load as %s: %s', fmt, path)
134
+            continue
135
+        except OSError as exc:
136
+            click.echo(
137
+                (
138
+                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
139
+                    f'a valid config: {exc.strerror}: {exc.filename!r}'
140
+                ),
141
+                err=True,
142
+            )
143
+            ctx.exit(1)
144
+        except ModuleNotFoundError:
145
+            # TODO(the-13th-letter): Use backslash continuation.
146
+            # https://github.com/nedbat/coveragepy/issues/1836
147
+            msg = f"""
148
+{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
149
+{PROG_NAME}: INFO: pip users: see the "export" extra.
150
+""".lstrip('\n')
151
+            click.echo(msg, nl=False, err=True)
152
+            ctx.exit(1)
153
+        else:
154
+            if not _types.is_vault_config(config):
155
+                click.echo(
156
+                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
157
+                    err=True,
158
+                )
159
+                ctx.exit(1)
160
+            click.echo(json.dumps(config, indent=2, sort_keys=True))
161
+            break
162
+    else:
163
+        click.echo(
164
+            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
165
+            err=True,
166
+        )
167
+        ctx.exit(1)
... ...
@@ -31,7 +31,7 @@ else:
31 31
         from cryptography.hazmat.primitives.kdf import pbkdf2
32 32
     except ModuleNotFoundError as exc:
33 33
 
34
-        class DummyModule:
34
+        class DummyModule:  # pragma: no cover
35 35
             def __init__(self, exc: type[Exception]) -> None:
36 36
                 self.exc = exc
37 37
 
... ...
@@ -29,7 +29,7 @@ else:
29 29
         from cryptography.hazmat.primitives.kdf import pbkdf2
30 30
     except ModuleNotFoundError as exc:
31 31
 
32
-        class DummyModule:
32
+        class DummyModule:  # pragma: no cover
33 33
             def __init__(self, exc: type[Exception]) -> None:
34 34
                 self.exc = exc
35 35
 
... ...
@@ -350,6 +350,99 @@ DUMMY_PHRASE_FROM_KEY1_RAW = (
350 350
 )
351 351
 DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg=='  # noqa: E501
352 352
 
353
+VAULT_MASTER_KEY = 'vault key'
354
+VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi'  # noqa: E501
355
+VAULT_V02_CONFIG_DATA = {
356
+    'global': {
357
+        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
358
+    },
359
+    'services': {
360
+        '(meta)': {
361
+            'notes': 'This config was originally in v0.2 format.',
362
+        },
363
+        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
364
+    },
365
+}
366
+VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7'  # noqa: E501
367
+VAULT_V03_CONFIG_DATA = {
368
+    'global': {
369
+        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
370
+    },
371
+    'services': {
372
+        '(meta)': {
373
+            'notes': 'This config was originally in v0.3 format.',
374
+        },
375
+        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
376
+    },
377
+}
378
+VAULT_STOREROOM_CONFIG_ZIPPED = b"""
379
+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
429
+"""
430
+VAULT_STOREROOM_CONFIG_DATA = {
431
+    'global': {
432
+        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
433
+    },
434
+    'services': {
435
+        '(meta)': {
436
+            'notes': 'This config was originally in storeroom format.',
437
+        },
438
+        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
439
+    },
440
+}
441
+
442
+CANNOT_LOAD_CRYPTOGRAPHY = (
443
+    b'Cannot load the required Python module "cryptography".'
444
+)
445
+
353 446
 skip_if_no_agent = pytest.mark.skipif(
354 447
     not os.environ.get('SSH_AUTH_SOCK'), reason='running SSH agent required'
355 448
 )
... ...
@@ -427,7 +520,7 @@ def isolated_vault_exporter_config(
427 520
         except AttributeError:
428 521
 
429 522
             @contextlib.contextmanager
430
-            def chdir(newpath: str) -> Iterator[None]:
523
+            def chdir(newpath: str) -> Iterator[None]:  # pragma: no branch
431 524
                 oldpath = os.getcwd()
432 525
                 os.chdir(newpath)
433 526
                 yield
... ...
@@ -13,107 +13,7 @@ import pytest
13 13
 
14 14
 import tests
15 15
 from derivepassphrase import exporter
16
-
17
-VAULT_MASTER_KEY = 'vault key'
18
-VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi'  # noqa: E501
19
-VAULT_V02_CONFIG_DATA = {
20
-    'global': {
21
-        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
22
-    },
23
-    'services': {
24
-        '(meta)': {
25
-            'notes': 'This config was originally in v0.2 format.',
26
-        },
27
-        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
28
-    },
29
-}
30
-VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7'  # noqa: E501
31
-VAULT_V03_CONFIG_DATA = {
32
-    'global': {
33
-        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
34
-    },
35
-    'services': {
36
-        '(meta)': {
37
-            'notes': 'This config was originally in v0.3 format.',
38
-        },
39
-        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
40
-    },
41
-}
42
-VAULT_STOREROOM_CONFIG_ZIPPED = b"""
43
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-EPrxoE08fu+v6DKnSopnG8GTkbscPZ+K5q2kC6m7pCizKO1sLKG7fMBRnJxnel/vmpY2lFCB4ADy
60
-no+dvqBl6z3X/ji9AFXC9X8HRd+8u57OS1zV4OhiVd7hMy1U8F5qbIBms+FS6QbL9NhIb2lFN4VO
61
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-XVPFxaKG4p9Iux/xazf48FY8O7SMmbQC1VsXIYo+7eSpIY67VzrCoh41wXPklOWS6CV8RR/JBSqq
76
-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
-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
-AgOcrM+JFvpYA86Ja8HCqCg8LihEI1e7+m8F71Lpavv/UEsDBBQAAAAIAJ1WGVnKO2Ji+AEAAGsC
79
-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
-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
-LnSIZVuen5Eua9QY8lQ7rxW7YIqeajhgLfL54BIcY90fd8ANixlcM8V23Z03U35Txba0BbSguc0f
82
-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
-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
-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
-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
-+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
-AFbsmNYTJ9GmULB9lXmTWmrME592S285iWU5SsJcE1s+3oQw9QrvWB+e3bGAd9e+VFmFqr6+/gFQ
88
-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
-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
-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
-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
-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
93
-"""
94
-VAULT_STOREROOM_CONFIG_DATA = {
95
-    'global': {
96
-        'phrase': tests.DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
97
-    },
98
-    'services': {
99
-        '(meta)': {
100
-            'notes': 'This config was originally in storeroom format.',
101
-        },
102
-        tests.DUMMY_SERVICE: tests.DUMMY_CONFIG_SETTINGS.copy(),
103
-    },
104
-}
105
-
106
-try:
107
-    from cryptography.hazmat.primitives import ciphers
108
-except ModuleNotFoundError:
109
-    CRYPTOGRAPHY_SUPPORT = False
110
-else:
111
-    CRYPTOGRAPHY_SUPPORT = True
112
-    del ciphers
113
-
114
-CANNOT_LOAD_CRYPTOGRAPHY = (
115
-    b'Cannot load the required Python module "cryptography".'
116
-)
16
+from derivepassphrase.exporter import cli
117 17
 
118 18
 
119 19
 class Test001ExporterUtils:
... ...
@@ -206,91 +106,6 @@ class Test001ExporterUtils:
206 106
 
207 107
 
208 108
 class Test002CLI:
209
-    @pytest.mark.xfail(
210
-        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
211
-    )
212
-    def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
213
-        runner = click.testing.CliRunner(mix_stderr=False)
214
-        with tests.isolated_vault_exporter_config(
215
-            monkeypatch=monkeypatch,
216
-            runner=runner,
217
-            vault_config=VAULT_V03_CONFIG,
218
-            vault_key=VAULT_MASTER_KEY,
219
-        ):
220
-            monkeypatch.setenv('VAULT_KEY', VAULT_MASTER_KEY)
221
-            result = runner.invoke(
222
-                exporter.derivepassphrase_export,
223
-                ['VAULT_PATH'],
224
-            )
225
-        assert not result.exception
226
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
227
-        assert json.loads(result.stdout) == VAULT_V03_CONFIG_DATA
228
-
229
-    @pytest.mark.xfail(
230
-        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
231
-    )
232
-    def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
233
-        runner = click.testing.CliRunner(mix_stderr=False)
234
-        with tests.isolated_vault_exporter_config(
235
-            monkeypatch=monkeypatch,
236
-            runner=runner,
237
-            vault_config=VAULT_V03_CONFIG,
238
-        ):
239
-            result = runner.invoke(
240
-                exporter.derivepassphrase_export,
241
-                ['-k', VAULT_MASTER_KEY, '.vault'],
242
-            )
243
-        assert not result.exception
244
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
245
-        assert json.loads(result.stdout) == VAULT_V03_CONFIG_DATA
246
-
247
-    @pytest.mark.xfail(
248
-        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
249
-    )
250
-    @pytest.mark.parametrize(
251
-        ['version', 'config', 'config_data'],
252
-        [
253
-            pytest.param(
254
-                '0.2', VAULT_V02_CONFIG, VAULT_V02_CONFIG_DATA, id='0.2'
255
-            ),
256
-            pytest.param(
257
-                '0.3', VAULT_V03_CONFIG, VAULT_V03_CONFIG_DATA, id='0.3'
258
-            ),
259
-            pytest.param(
260
-                'storeroom',
261
-                VAULT_STOREROOM_CONFIG_ZIPPED,
262
-                VAULT_STOREROOM_CONFIG_DATA,
263
-                id='storeroom',
264
-            ),
265
-        ],
266
-    )
267
-    def test_210_load_vault_v02_v03_storeroom(
268
-        self,
269
-        monkeypatch: pytest.MonkeyPatch,
270
-        version: str,
271
-        config: str | bytes,
272
-        config_data: dict[str, Any],
273
-    ) -> None:
274
-        runner = click.testing.CliRunner(mix_stderr=False)
275
-        with tests.isolated_vault_exporter_config(
276
-            monkeypatch=monkeypatch,
277
-            runner=runner,
278
-            vault_config=config,
279
-        ):
280
-            result = runner.invoke(
281
-                exporter.derivepassphrase_export,
282
-                [
283
-                    '-f',
284
-                    f'v{version}' if version.startswith('0') else version,
285
-                    '-k',
286
-                    VAULT_MASTER_KEY,
287
-                    'VAULT_PATH',
288
-                ],
289
-            )
290
-        assert not result.exception
291
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
292
-        assert json.loads(result.stdout) == config_data
293
-
294 109
     def test_300_invalid_format(
295 110
         self,
296 111
         monkeypatch: pytest.MonkeyPatch,
... ...
@@ -299,11 +114,11 @@ class Test002CLI:
299 114
         with tests.isolated_vault_exporter_config(
300 115
             monkeypatch=monkeypatch,
301 116
             runner=runner,
302
-            vault_config=VAULT_V03_CONFIG,
303
-            vault_key=VAULT_MASTER_KEY,
117
+            vault_config=tests.VAULT_V03_CONFIG,
118
+            vault_key=tests.VAULT_MASTER_KEY,
304 119
         ):
305 120
             result = runner.invoke(
306
-                exporter.derivepassphrase_export,
121
+                cli.derivepassphrase_export,
307 122
                 ['-f', 'INVALID', 'VAULT_PATH'],
308 123
                 catch_exceptions=False,
309 124
             )
... ...
@@ -314,68 +129,3 @@ class Test002CLI:
314 129
         assert b'-f' in result.stderr_bytes
315 130
         assert b'--format' in result.stderr_bytes
316 131
         assert b'INVALID' in result.stderr_bytes
317
-
318
-    @pytest.mark.xfail(
319
-        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
320
-    )
321
-    def test_301_vault_config_not_found(
322
-        self,
323
-        monkeypatch: pytest.MonkeyPatch,
324
-    ) -> None:
325
-        runner = click.testing.CliRunner(mix_stderr=False)
326
-        with tests.isolated_vault_exporter_config(
327
-            monkeypatch=monkeypatch,
328
-            runner=runner,
329
-            vault_config=VAULT_V03_CONFIG,
330
-            vault_key=VAULT_MASTER_KEY,
331
-        ):
332
-            result = runner.invoke(
333
-                exporter.derivepassphrase_export,
334
-                ['does-not-exist.txt'],
335
-            )
336
-        assert isinstance(result.exception, SystemExit)
337
-        assert result.exit_code
338
-        assert result.stderr_bytes
339
-        assert (
340
-            b"Cannot parse 'does-not-exist.txt' as a valid config"
341
-            in result.stderr_bytes
342
-        )
343
-        assert CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
344
-
345
-    @pytest.mark.xfail(
346
-        not CRYPTOGRAPHY_SUPPORT, reason='cryptography module not found'
347
-    )
348
-    def test_302_vault_config_invalid(
349
-        self,
350
-        monkeypatch: pytest.MonkeyPatch,
351
-    ) -> None:
352
-        runner = click.testing.CliRunner(mix_stderr=False)
353
-        with tests.isolated_vault_exporter_config(
354
-            monkeypatch=monkeypatch,
355
-            runner=runner,
356
-            vault_config='',
357
-            vault_key=VAULT_MASTER_KEY,
358
-        ):
359
-            result = runner.invoke(
360
-                exporter.derivepassphrase_export,
361
-                ['.vault'],
362
-            )
363
-        assert isinstance(result.exception, SystemExit)
364
-        assert result.exit_code
365
-        assert result.stderr_bytes
366
-        assert (
367
-            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
368
-        )
369
-        assert CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
370
-
371
-
372
-class TestStoreroomExporter:
373
-    pass  # TODO(the-13th-letter): Fill in once design is stable.
374
-
375
-
376
-class TestV02Exporter:
377
-    pass  # TODO(the-13th-letter): Fill in once design is stable.
378
-
379
-
380
-class TestV03Exporter:
381
-    pass  # TODO(the-13th-letter): Fill in once design is stable.
... ...
@@ -0,0 +1,146 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+from __future__ import annotations
6
+
7
+import json
8
+import os
9
+from typing import Any
10
+
11
+import click.testing
12
+import pytest
13
+
14
+import tests
15
+from derivepassphrase import exporter
16
+from derivepassphrase.exporter import cli
17
+
18
+cryptography = pytest.importorskip('cryptography', minversion='38.0')
19
+
20
+
21
+class TestCLI:
22
+    def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
23
+        runner = click.testing.CliRunner(mix_stderr=False)
24
+        with tests.isolated_vault_exporter_config(
25
+            monkeypatch=monkeypatch,
26
+            runner=runner,
27
+            vault_config=tests.VAULT_V03_CONFIG,
28
+            vault_key=tests.VAULT_MASTER_KEY,
29
+        ):
30
+            monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
31
+            result = runner.invoke(
32
+                cli.derivepassphrase_export,
33
+                ['VAULT_PATH'],
34
+            )
35
+        assert not result.exception
36
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
37
+        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
38
+
39
+    def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
40
+        runner = click.testing.CliRunner(mix_stderr=False)
41
+        with tests.isolated_vault_exporter_config(
42
+            monkeypatch=monkeypatch,
43
+            runner=runner,
44
+            vault_config=tests.VAULT_V03_CONFIG,
45
+        ):
46
+            result = runner.invoke(
47
+                cli.derivepassphrase_export,
48
+                ['-k', tests.VAULT_MASTER_KEY, '.vault'],
49
+            )
50
+        assert not result.exception
51
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
52
+        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
53
+
54
+    @pytest.mark.parametrize(
55
+        ['format', 'config', 'config_data'],
56
+        [
57
+            pytest.param(
58
+                'v0.2',
59
+                tests.VAULT_V02_CONFIG,
60
+                tests.VAULT_V02_CONFIG_DATA,
61
+                id='0.2',
62
+            ),
63
+            pytest.param(
64
+                'v0.3',
65
+                tests.VAULT_V03_CONFIG,
66
+                tests.VAULT_V03_CONFIG_DATA,
67
+                id='0.3',
68
+            ),
69
+            pytest.param(
70
+                'storeroom',
71
+                tests.VAULT_STOREROOM_CONFIG_ZIPPED,
72
+                tests.VAULT_STOREROOM_CONFIG_DATA,
73
+                id='storeroom',
74
+            ),
75
+        ],
76
+    )
77
+    def test_210_load_vault_v02_v03_storeroom(
78
+        self,
79
+        monkeypatch: pytest.MonkeyPatch,
80
+        format: str,
81
+        config: str | bytes,
82
+        config_data: dict[str, Any],
83
+    ) -> None:
84
+        runner = click.testing.CliRunner(mix_stderr=False)
85
+        with tests.isolated_vault_exporter_config(
86
+            monkeypatch=monkeypatch,
87
+            runner=runner,
88
+            vault_config=config,
89
+        ):
90
+            result = runner.invoke(
91
+                cli.derivepassphrase_export,
92
+                ['-f', format, '-k', tests.VAULT_MASTER_KEY, 'VAULT_PATH'],
93
+            )
94
+        assert not result.exception
95
+        assert (result.exit_code, result.stderr_bytes) == (0, b'')
96
+        assert json.loads(result.stdout) == config_data
97
+
98
+    # test_300_invalid_format is found in
99
+    # tests.test_derivepassphrase_export::Test002CLI
100
+
101
+    def test_301_vault_config_not_found(
102
+        self,
103
+        monkeypatch: pytest.MonkeyPatch,
104
+    ) -> None:
105
+        runner = click.testing.CliRunner(mix_stderr=False)
106
+        with tests.isolated_vault_exporter_config(
107
+            monkeypatch=monkeypatch,
108
+            runner=runner,
109
+            vault_config=tests.VAULT_V03_CONFIG,
110
+            vault_key=tests.VAULT_MASTER_KEY,
111
+        ):
112
+            result = runner.invoke(
113
+                cli.derivepassphrase_export,
114
+                ['does-not-exist.txt'],
115
+            )
116
+        assert isinstance(result.exception, SystemExit)
117
+        assert result.exit_code
118
+        assert result.stderr_bytes
119
+        assert (
120
+            b"Cannot parse 'does-not-exist.txt' as a valid config"
121
+            in result.stderr_bytes
122
+        )
123
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
124
+
125
+    def test_302_vault_config_invalid(
126
+        self,
127
+        monkeypatch: pytest.MonkeyPatch,
128
+    ) -> None:
129
+        runner = click.testing.CliRunner(mix_stderr=False)
130
+        with tests.isolated_vault_exporter_config(
131
+            monkeypatch=monkeypatch,
132
+            runner=runner,
133
+            vault_config='',
134
+            vault_key=tests.VAULT_MASTER_KEY,
135
+        ):
136
+            result = runner.invoke(
137
+                cli.derivepassphrase_export,
138
+                ['.vault'],
139
+            )
140
+        assert isinstance(result.exception, SystemExit)
141
+        assert result.exit_code
142
+        assert result.stderr_bytes
143
+        assert (
144
+            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
145
+        )
146
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
0 147