Add command-line interface to the exporter
Marco Ricci

Marco Ricci commited on 2024-08-25 23:31:24
Zeige 2 geänderte Dateien mit 167 Einfügungen und 0 Löschungen.


A new script `derivepassphrase_export` implements a command-line
interface to the exporter machinery.  It is implemented as a separate
script because it has a very different call signature which doesn't
easily map to `derivepassphrase`'s command-line interface.
... ...
@@ -46,6 +46,7 @@ Source = "https://github.com/the-13th-letter/derivepassphrase"
46 46
 
47 47
 [project.scripts]
48 48
 derivepassphrase = "derivepassphrase.cli:derivepassphrase"
49
+derivepassphrase_export = "derivepassphrase.exporter:derivepassphrase_export"
49 50
 
50 51
 [tool.mypy]
51 52
 files = ['src/**/*.py', 'tests/**/*.py']
... ...
@@ -73,6 +74,7 @@ packages = ['src/derivepassphrase']
73 74
 
74 75
 [tool.hatch.envs.hatch-test]
75 76
 default-args = ['src', 'tests']
77
+features = ["export"]
76 78
 
77 79
 [[tool.hatch.envs.hatch-test.matrix]]
78 80
 python = ["3.10", "3.11", "3.12", "pypy3.10"]
... ...
@@ -106,6 +108,10 @@ extra-dependencies = [
106 108
   "mypy>=1.0.0",
107 109
   "pytest~=8.1",
108 110
 ]
111
+features = [
112
+    "export",
113
+]
114
+
109 115
 [tool.hatch.envs.types.scripts]
110 116
 check = "mypy --install-types --non-interactive {args:src/derivepassphrase tests}"
111 117
 
... ...
@@ -137,6 +143,7 @@ exclude_also = [
137 143
   "raise AssertionError",
138 144
   "raise NotImplementedError",
139 145
   'assert False',
146
+  '(?:typing\.)?assert_never\(',
140 147
 ]
141 148
 
142 149
 [tool.ruff]
... ...
@@ -1,4 +1,34 @@
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
1 13
 import os
14
+from typing import TYPE_CHECKING, Any, Literal
15
+
16
+import click
17
+from typing_extensions import assert_never
18
+
19
+import derivepassphrase as dpp
20
+from derivepassphrase import _types
21
+
22
+if TYPE_CHECKING:
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'
2 32
 
3 33
 
4 34
 def get_vault_key() -> bytes:
... ...
@@ -56,3 +86,133 @@ def get_vault_path() -> str | bytes | os.PathLike:
56 86
         msg = 'Cannot determine home directory'
57 87
         raise RuntimeError(msg)
58 88
     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
+                with open(path, 'rb') as infile:
151
+                    contents = base64.standard_b64decode(infile.read())
152
+                return module.V02Reader(contents, key).run()
153
+            case 'v0.3':
154
+                module = importlib.import_module(
155
+                    'derivepassphrase.exporter.vault_v03_and_below'
156
+                )
157
+                with open(path, 'rb') as infile:
158
+                    contents = base64.standard_b64decode(infile.read())
159
+                return module.V03Reader(contents, key).run()
160
+            case 'storeroom':
161
+                module = importlib.import_module(
162
+                    'derivepassphrase.exporter.storeroom'
163
+                )
164
+                return module.export_storeroom_data(path, key)
165
+            case _:  # pragma: no cover
166
+                assert_never(fmt)
167
+
168
+    logging.basicConfig()
169
+    if path in {'VAULT_PATH', b'VAULT_PATH'}:
170
+        path = get_vault_path()
171
+    if key is None:
172
+        key = get_vault_key()
173
+    elif isinstance(key, str):  # pragma: no branch
174
+        key = key.encode('utf-8')
175
+    for fmt in formats:
176
+        try:
177
+            config = load_data(fmt, path, key)
178
+        except (
179
+            IsADirectoryError,
180
+            NotADirectoryError,
181
+            ValueError,
182
+            RuntimeError,
183
+        ):
184
+            logging.info('Cannot load as %s: %s', fmt, path)
185
+            continue
186
+        except OSError as exc:
187
+            click.echo(
188
+                (
189
+                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
190
+                    f'a valid config: {exc.strerror}: {exc.filename!r}'
191
+                ),
192
+                err=True,
193
+            )
194
+            ctx.exit(1)
195
+        except ModuleNotFoundError:
196
+            # TODO(the-13th-letter): Use backslash continuation.
197
+            # https://github.com/nedbat/coveragepy/issues/1836
198
+            msg = f"""
199
+{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
200
+{PROG_NAME}: INFO: pip users: see the "export" extra.
201
+""".lstrip('\n')
202
+            click.echo(msg, nl=False, err=True)
203
+            ctx.exit(1)
204
+        else:
205
+            if not _types.is_vault_config(config):
206
+                click.echo(
207
+                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
208
+                    err=True,
209
+                )
210
+                ctx.exit(1)
211
+            click.echo(json.dumps(config, indent=2, sort_keys=True))
212
+            break
213
+    else:
214
+        click.echo(
215
+            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
216
+            err=True,
217
+        )
218
+        ctx.exit(1)
59 219