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 |