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 |