2d292af3e81527750e46a2167d30efe840ac58ca
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

1) # SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2) #
3) # SPDX-License-Identifier: MIT
4) 
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

5) """Command-line interface for derivepassphrase."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

6) 
7) from __future__ import annotations
8) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

9) import base64
10) import collections
11) import contextlib
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

12) import copy
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

13) import inspect
14) import json
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

15) import os
16) import socket
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

17) import unicodedata
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

18) from typing import (
19)     TYPE_CHECKING,
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

20)     Literal,
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

21)     NoReturn,
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

22)     TextIO,
23)     cast,
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

24) )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

25) 
26) import click
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

27) from typing_extensions import (
28)     Any,
29)     assert_never,
30) )
31) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

32) import derivepassphrase as dpp
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

33) from derivepassphrase import _types, ssh_agent, vault
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

34) 
35) if TYPE_CHECKING:
36)     import pathlib
37)     from collections.abc import (
38)         Iterator,
39)         Sequence,
40)     )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

41) 
42) __author__ = dpp.__author__
43) __version__ = dpp.__version__
44) 
45) __all__ = ('derivepassphrase',)
46) 
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

47) PROG_NAME = 'derivepassphrase'
48) KEY_DISPLAY_LENGTH = 30
49) 
50) # Error messages
51) _INVALID_VAULT_CONFIG = 'Invalid vault config'
52) _AGENT_COMMUNICATION_ERROR = 'Error communicating with the SSH agent'
53) _NO_USABLE_KEYS = 'No usable SSH keys were found'
54) _EMPTY_SELECTION = 'Empty selection'
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

55) 
56) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

57) def _config_filename() -> str | bytes | pathlib.Path:
58)     """Return the filename of the configuration file.
59) 
60)     The file is currently named `settings.json`, located within the
61)     configuration directory as determined by the `DERIVEPASSPHRASE_PATH`
62)     environment variable, or by [`click.get_app_dir`][] in POSIX
63)     mode.
64) 
65)     """
66)     path: str | bytes | pathlib.Path
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

67)     path = os.getenv(PROG_NAME.upper() + '_PATH') or click.get_app_dir(
68)         PROG_NAME, force_posix=True
69)     )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

70)     return os.path.join(path, 'settings.json')
71) 
72) 
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

73) def _load_config() -> _types.VaultConfig:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

74)     """Load a vault(1)-compatible config from the application directory.
75) 
76)     The filename is obtained via
77)     [`derivepassphrase.cli._config_filename`][].  This must be an
78)     unencrypted JSON file.
79) 
80)     Returns:
81)         The vault settings.  See
82)         [`derivepassphrase.types.VaultConfig`][] for details.
83) 
84)     Raises:
85)         OSError:
86)             There was an OS error accessing the file.
87)         ValueError:
88)             The data loaded from the file is not a vault(1)-compatible
89)             config.
90) 
91)     """
92)     filename = _config_filename()
93)     with open(filename, 'rb') as fileobj:
94)         data = json.load(fileobj)
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

95)     if not _types.is_vault_config(data):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

96)         raise ValueError(_INVALID_VAULT_CONFIG)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

97)     return data
98) 
99) 
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

100) def _save_config(config: _types.VaultConfig, /) -> None:
Marco Ricci Create the configuration di...

Marco Ricci authored 1 month ago

101)     """Save a vault(1)-compatible config to the application directory.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

102) 
103)     The filename is obtained via
104)     [`derivepassphrase.cli._config_filename`][].  The config will be
105)     stored as an unencrypted JSON file.
106) 
107)     Args:
108)         config:
109)             vault configuration to save.
110) 
111)     Raises:
112)         OSError:
113)             There was an OS error accessing or writing the file.
114)         ValueError:
115)             The data cannot be stored as a vault(1)-compatible config.
116) 
117)     """
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

118)     if not _types.is_vault_config(config):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

119)         raise ValueError(_INVALID_VAULT_CONFIG)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

120)     filename = _config_filename()
Marco Ricci Create the configuration di...

Marco Ricci authored 1 month ago

121)     filedir = os.path.dirname(os.path.abspath(filename))
122)     try:
123)         os.makedirs(filedir, exist_ok=False)
124)     except FileExistsError:
125)         if not os.path.isdir(filedir):
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

126)             raise  # noqa: DOC501
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

127)     with open(filename, 'w', encoding='UTF-8') as fileobj:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

128)         json.dump(config, fileobj)
129) 
130) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

131) def _get_suitable_ssh_keys(
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

132)     conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

133) ) -> Iterator[_types.KeyCommentPair]:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

134)     """Yield all SSH keys suitable for passphrase derivation.
135) 
136)     Suitable SSH keys are queried from the running SSH agent (see
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

137)     [`ssh_agent.SSHAgentClient.list_keys`][]).
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

138) 
139)     Args:
140)         conn:
141)             An optional connection hint to the SSH agent; specifically,
142)             an SSH agent client, or a socket connected to an SSH agent.
143) 
144)             If an existing SSH agent client, then this client will be
145)             queried for the SSH keys, and otherwise left intact.
146) 
147)             If a socket, then a one-shot client will be constructed
148)             based on the socket to query the agent, and deconstructed
149)             afterwards.
150) 
151)             If neither are given, then the agent's socket location is
152)             looked up in the `SSH_AUTH_SOCK` environment variable, and
153)             used to construct/deconstruct a one-shot client, as in the
154)             previous case.
155) 
156)     Yields:
157)         :
158)             Every SSH key from the SSH agent that is suitable for
159)             passphrase derivation.
160) 
161)     Raises:
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

162)         KeyError:
163)             `conn` was `None`, and the `SSH_AUTH_SOCK` environment
164)             variable was not found.
165)         OSError:
166)             `conn` was a socket or `None`, and there was an error
167)             setting up a socket connection to the agent.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

168)         LookupError:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

169)             No keys usable for passphrase derivation are loaded into the
170)             SSH agent.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

171)         RuntimeError:
172)             There was an error communicating with the SSH agent.
Marco Ricci Add a specific error class...

Marco Ricci authored 1 month ago

173)         SSHAgentFailedError:
174)             The agent failed to supply a list of loaded keys.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

175) 
176)     """
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

177)     client: ssh_agent.SSHAgentClient
Marco Ricci Fix typing issues in mypy s...

Marco Ricci authored 1 month ago

178)     client_context: contextlib.AbstractContextManager[Any]
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

179)     match conn:
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

180)         case ssh_agent.SSHAgentClient():
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

181)             client = conn
182)             client_context = contextlib.nullcontext()
183)         case socket.socket() | None:
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

184)             client = ssh_agent.SSHAgentClient(socket=conn)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

185)             client_context = client
186)         case _:  # pragma: no cover
187)             assert_never(conn)
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

188)             msg = f'invalid connection hint: {conn!r}'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

189)             raise TypeError(msg)  # noqa: DOC501
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

190)     with client_context:
191)         try:
192)             all_key_comment_pairs = list(client.list_keys())
193)         except EOFError as e:  # pragma: no cover
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

194)             raise RuntimeError(_AGENT_COMMUNICATION_ERROR) from e
195)     suitable_keys = copy.copy(all_key_comment_pairs)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

196)     for pair in all_key_comment_pairs:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

197)         key, _comment = pair
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

198)         if vault.Vault._is_suitable_ssh_key(key):  # noqa: SLF001
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

199)             yield pair
200)     if not suitable_keys:  # pragma: no cover
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

201)         raise LookupError(_NO_USABLE_KEYS)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

202) 
203) 
204) def _prompt_for_selection(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

205)     items: Sequence[str | bytes],
206)     heading: str = 'Possible choices:',
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

207)     single_choice_prompt: str = 'Confirm this choice?',
208) ) -> int:
209)     """Prompt user for a choice among the given items.
210) 
211)     Print the heading, if any, then present the items to the user.  If
212)     there are multiple items, prompt the user for a selection, validate
213)     the choice, then return the list index of the selected item.  If
214)     there is only a single item, request confirmation for that item
215)     instead, and return the correct index.
216) 
217)     Args:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

218)         items:
219)             The list of items to choose from.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

220)         heading:
221)             A heading for the list of items, to print immediately
222)             before.  Defaults to a reasonable standard heading.  If
223)             explicitly empty, print no heading.
224)         single_choice_prompt:
225)             The confirmation prompt if there is only a single possible
226)             choice.  Defaults to a reasonable standard prompt.
227) 
228)     Returns:
229)         An index into the items sequence, indicating the user's
230)         selection.
231) 
232)     Raises:
233)         IndexError:
234)             The user made an invalid or empty selection, or requested an
235)             abort.
236) 
237)     """
238)     n = len(items)
239)     if heading:
240)         click.echo(click.style(heading, bold=True))
241)     for i, x in enumerate(items, start=1):
242)         click.echo(click.style(f'[{i}]', bold=True), nl=False)
243)         click.echo(' ', nl=False)
244)         click.echo(x)
245)     if n > 1:
246)         choices = click.Choice([''] + [str(i) for i in range(1, n + 1)])
247)         choice = click.prompt(
248)             f'Your selection? (1-{n}, leave empty to abort)',
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

249)             err=True,
250)             type=choices,
251)             show_choices=False,
252)             show_default=False,
253)             default='',
254)         )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

255)         if not choice:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

256)             raise IndexError(_EMPTY_SELECTION)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

257)         return int(choice) - 1
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

258)     prompt_suffix = (
259)         ' ' if single_choice_prompt.endswith(tuple('?.!')) else ': '
260)     )
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

261)     try:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

262)         click.confirm(
263)             single_choice_prompt,
264)             prompt_suffix=prompt_suffix,
265)             err=True,
266)             abort=True,
267)             default=False,
268)             show_default=False,
269)         )
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

270)     except click.Abort:
271)         raise IndexError(_EMPTY_SELECTION) from None
272)     return 0
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

273) 
274) 
275) def _select_ssh_key(
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

276)     conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

277) ) -> bytes | bytearray:
278)     """Interactively select an SSH key for passphrase derivation.
279) 
280)     Suitable SSH keys are queried from the running SSH agent (see
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

281)     [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

282)     prompted interactively (see [`click.prompt`][]) for a selection.
283) 
284)     Args:
285)         conn:
286)             An optional connection hint to the SSH agent; specifically,
287)             an SSH agent client, or a socket connected to an SSH agent.
288) 
289)             If an existing SSH agent client, then this client will be
290)             queried for the SSH keys, and otherwise left intact.
291) 
292)             If a socket, then a one-shot client will be constructed
293)             based on the socket to query the agent, and deconstructed
294)             afterwards.
295) 
296)             If neither are given, then the agent's socket location is
297)             looked up in the `SSH_AUTH_SOCK` environment variable, and
298)             used to construct/deconstruct a one-shot client, as in the
299)             previous case.
300) 
301)     Returns:
302)         The selected SSH key.
303) 
304)     Raises:
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

305)         KeyError:
306)             `conn` was `None`, and the `SSH_AUTH_SOCK` environment
307)             variable was not found.
308)         OSError:
309)             `conn` was a socket or `None`, and there was an error
310)             setting up a socket connection to the agent.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

311)         IndexError:
312)             The user made an invalid or empty selection, or requested an
313)             abort.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

314)         LookupError:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

315)             No keys usable for passphrase derivation are loaded into the
316)             SSH agent.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

317)         RuntimeError:
318)             There was an error communicating with the SSH agent.
Marco Ricci Add a specific error class...

Marco Ricci authored 1 month ago

319)         SSHAgentFailedError:
320)             The agent failed to supply a list of loaded keys.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

321)     """
322)     suitable_keys = list(_get_suitable_ssh_keys(conn))
323)     key_listing: list[str] = []
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

324)     unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

325)     for key, comment in suitable_keys:
326)         keytype = unstring_prefix(key)[0].decode('ASCII')
327)         key_str = base64.standard_b64encode(key).decode('ASCII')
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

328)         key_prefix = (
329)             key_str
330)             if len(key_str) < KEY_DISPLAY_LENGTH + len('...')
331)             else key_str[:KEY_DISPLAY_LENGTH] + '...'
332)         )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

333)         comment_str = comment.decode('UTF-8', errors='replace')
334)         key_listing.append(f'{keytype} {key_prefix} {comment_str}')
335)     choice = _prompt_for_selection(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

336)         key_listing,
337)         heading='Suitable SSH keys:',
338)         single_choice_prompt='Use this key?',
339)     )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

340)     return suitable_keys[choice].key
341) 
342) 
343) def _prompt_for_passphrase() -> str:
344)     """Interactively prompt for the passphrase.
345) 
346)     Calls [`click.prompt`][] internally.  Moved into a separate function
347)     mainly for testing/mocking purposes.
348) 
349)     Returns:
350)         The user input.
351) 
352)     """
Marco Ricci Fix typing issues in mypy s...

Marco Ricci authored 1 month ago

353)     return cast(
354)         str,
355)         click.prompt(
356)             'Passphrase',
357)             default='',
358)             hide_input=True,
359)             show_default=False,
360)             err=True,
361)         ),
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

362)     )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

363) 
364) 
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

365) def _check_for_misleading_passphrase(
366)     key: tuple[str, ...],
367)     value: dict[str, Any],
368)     *,
369)     form: Literal['NFC', 'NFD', 'NFKC', 'NFKD'] = 'NFC',
370) ) -> None:
371)     def is_json_identifier(x: str) -> bool:
372)         return not x.startswith(tuple('0123456789')) and not any(
373)             c.lower() not in set('0123456789abcdefghijklmnopqrstuvwxyz_')
374)             for c in x
375)         )
376) 
377)     if 'phrase' in value:
378)         phrase = value['phrase']
379)         if not unicodedata.is_normalized(form, phrase):
380)             key_path = '.'.join(
381)                 x if is_json_identifier(x) else repr(x) for x in key
382)             )
383)             click.echo(
384)                 (
385)                     f'{PROG_NAME}: Warning: the {key_path} passphrase '
386)                     f'is not {form}-normalized. Make sure to double-check '
387)                     f'this is really the passphrase you want.'
388)                 ),
389)                 err=True,
390)             )
391) 
392) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

393) class OptionGroupOption(click.Option):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

394)     """A [`click.Option`][] with an associated group name and group epilog.
395) 
396)     Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print
397)     help sections.  Each subclass contains its own group name and
398)     epilog.
399) 
400)     Attributes:
401)         option_group_name:
402)             The name of the option group.  Used as a heading on the help
403)             text for options in this section.
404)         epilog:
405)             An epilog to print after listing the options in this
406)             section.
407) 
408)     """
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

409) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

410)     option_group_name: str = ''
411)     epilog: str = ''
412) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

413)     def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401
Marco Ricci Fix typing issues in mypy s...

Marco Ricci authored 1 month ago

414)         if self.__class__ == __class__:  # type: ignore[name-defined]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

415)             raise NotImplementedError
416)         super().__init__(*args, **kwargs)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

417) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

418) 
419) class CommandWithHelpGroups(click.Command):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

420)     """A [`click.Command`][] with support for help/option groups.
421) 
422)     Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
423)     further modified to support group epilogs.
424) 
425)     [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
426) 
427)     """
428) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

429)     def format_options(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

430)         self,
431)         ctx: click.Context,
432)         formatter: click.HelpFormatter,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

433)     ) -> None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

434)         r"""Format options on the help listing, grouped into sections.
435) 
Marco Ricci Add minor documentation rew...

Marco Ricci authored 2 months ago

436)         This is a callback for [`click.Command.get_help`][] that
437)         implements the `--help` listing, by calling appropriate methods
438)         of the `formatter`.  We list all options (like the base
439)         implementation), but grouped into sections according to the
440)         concrete [`click.Option`][] subclass being used.  If the option
441)         is an instance of some subclass `X` of
442)         [`derivepassphrase.cli.OptionGroupOption`][], then the section
443)         heading and the epilog are taken from `X.option_group_name` and
444)         `X.epilog`; otherwise, the section heading is "Options" (or
445)         "Other options" if there are other option groups) and the epilog
446)         is empty.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

447) 
448)         Args:
449)             ctx:
450)                 The click context.
451)             formatter:
452)                 The formatter for the `--help` listing.
453) 
454)         """
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

455)         help_records: dict[str, list[tuple[str, str]]] = {}
456)         epilogs: dict[str, str] = {}
457)         params = self.params[:]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

458)         if (  # pragma: no branch
459)             (help_opt := self.get_help_option(ctx)) is not None
460)             and help_opt not in params
461)         ):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

462)             params.append(help_opt)
463)         for param in params:
464)             rec = param.get_help_record(ctx)
465)             if rec is not None:
466)                 if isinstance(param, OptionGroupOption):
467)                     group_name = param.option_group_name
468)                     epilogs.setdefault(group_name, param.epilog)
469)                 else:
470)                     group_name = ''
471)                 help_records.setdefault(group_name, []).append(rec)
472)         default_group = help_records.pop('')
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

473)         default_group_name = (
474)             'Other Options' if len(default_group) > 1 else 'Options'
475)         )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

476)         help_records[default_group_name] = default_group
477)         for group_name, records in help_records.items():
478)             with formatter.section(group_name):
479)                 formatter.write_dl(records)
480)             epilog = inspect.cleandoc(epilogs.get(group_name, ''))
481)             if epilog:
482)                 formatter.write_paragraph()
483)                 with formatter.indentation():
484)                     formatter.write_text(epilog)
485) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

486) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

487) # Concrete option groups used by this command-line interface.
488) class PasswordGenerationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

489)     """Password generation options for the CLI."""
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

490) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

491)     option_group_name = 'Password generation'
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

492)     epilog = """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

493)         Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
494)         from the output.
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

495)     """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

496) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

497) 
498) class ConfigurationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

499)     """Configuration options for the CLI."""
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

500) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

501)     option_group_name = 'Configuration'
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

502)     epilog = """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

503)         Use $VISUAL or $EDITOR to configure the spawned editor.
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

504)     """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

505) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

506) 
507) class StorageManagementOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

508)     """Storage management options for the CLI."""
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

509) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

510)     option_group_name = 'Storage management'
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

511)     epilog = """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

512)         Using "-" as PATH for standard input/standard output is
513)         supported.
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

514)     """
515) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

516) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

517) def _validate_occurrence_constraint(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

518)     ctx: click.Context,
519)     param: click.Parameter,
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

520)     value: Any,  # noqa: ANN401
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

521) ) -> int | None:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

522)     """Check that the occurrence constraint is valid (int, 0 or larger).
523) 
524)     Args:
525)         ctx: The `click` context.
526)         param: The current command-line parameter.
527)         value: The parameter value to be checked.
528) 
529)     Returns:
530)         The parsed parameter value.
531) 
532)     Raises:
533)         click.BadParameter: The parameter value is invalid.
534) 
535)     """
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

536)     del ctx  # Unused.
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

537)     del param  # Unused.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

538)     if value is None:
539)         return value
540)     if isinstance(value, int):
541)         int_value = value
542)     else:
543)         try:
544)             int_value = int(value, 10)
545)         except ValueError as e:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

546)             msg = 'not an integer'
547)             raise click.BadParameter(msg) from e
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

548)     if int_value < 0:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

549)         msg = 'not a non-negative integer'
550)         raise click.BadParameter(msg)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

551)     return int_value
552) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

553) 
554) def _validate_length(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

555)     ctx: click.Context,
556)     param: click.Parameter,
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

557)     value: Any,  # noqa: ANN401
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

558) ) -> int | None:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

559)     """Check that the length is valid (int, 1 or larger).
560) 
561)     Args:
562)         ctx: The `click` context.
563)         param: The current command-line parameter.
564)         value: The parameter value to be checked.
565) 
566)     Returns:
567)         The parsed parameter value.
568) 
569)     Raises:
570)         click.BadParameter: The parameter value is invalid.
571) 
572)     """
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

573)     del ctx  # Unused.
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

574)     del param  # Unused.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

575)     if value is None:
576)         return value
577)     if isinstance(value, int):
578)         int_value = value
579)     else:
580)         try:
581)             int_value = int(value, 10)
582)         except ValueError as e:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

583)             msg = 'not an integer'
584)             raise click.BadParameter(msg) from e
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

585)     if int_value < 1:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

586)         msg = 'not a positive integer'
587)         raise click.BadParameter(msg)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

588)     return int_value
589) 
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

590) 
591) DEFAULT_NOTES_TEMPLATE = """\
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

592) # Enter notes below the line with the cut mark (ASCII scissors and
593) # dashes).  Lines above the cut mark (such as this one) will be ignored.
594) #
595) # If you wish to clear the notes, leave everything beyond the cut mark
596) # blank.  However, if you leave the *entire* file blank, also removing
597) # the cut mark, then the edit is aborted, and the old notes contents are
598) # retained.
599) #
600) # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

601) """
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

602) DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
603) 
604) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

605) @click.command(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

606)     context_settings={'help_option_names': ['-h', '--help']},
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

607)     cls=CommandWithHelpGroups,
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

608)     epilog=r"""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

609)         WARNING: There is NO WAY to retrieve the generated passphrases
610)         if the master passphrase, the SSH key, or the exact passphrase
611)         settings are lost, short of trying out all possible
612)         combinations.  You are STRONGLY advised to keep independent
613)         backups of the settings and the SSH key, if any.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

614) 
615)         Configuration is stored in a directory according to the
616)         DERIVEPASSPHRASE_PATH variable, which defaults to
617)         `~/.derivepassphrase` on UNIX-like systems and
618)         `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
619)         The configuration is NOT encrypted, and you are STRONGLY
620)         discouraged from using a stored passphrase.
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

621)     """,
622) )
623) @click.option(
624)     '-p',
625)     '--phrase',
626)     'use_phrase',
627)     is_flag=True,
628)     help='prompts you for your passphrase',
629)     cls=PasswordGenerationOption,
630) )
631) @click.option(
632)     '-k',
633)     '--key',
634)     'use_key',
635)     is_flag=True,
636)     help='uses your SSH private key to generate passwords',
637)     cls=PasswordGenerationOption,
638) )
639) @click.option(
640)     '-l',
641)     '--length',
642)     metavar='NUMBER',
643)     callback=_validate_length,
644)     help='emits password of length NUMBER',
645)     cls=PasswordGenerationOption,
646) )
647) @click.option(
648)     '-r',
649)     '--repeat',
650)     metavar='NUMBER',
651)     callback=_validate_occurrence_constraint,
652)     help='allows maximum of NUMBER repeated adjacent chars',
653)     cls=PasswordGenerationOption,
654) )
655) @click.option(
656)     '--lower',
657)     metavar='NUMBER',
658)     callback=_validate_occurrence_constraint,
659)     help='includes at least NUMBER lowercase letters',
660)     cls=PasswordGenerationOption,
661) )
662) @click.option(
663)     '--upper',
664)     metavar='NUMBER',
665)     callback=_validate_occurrence_constraint,
666)     help='includes at least NUMBER uppercase letters',
667)     cls=PasswordGenerationOption,
668) )
669) @click.option(
670)     '--number',
671)     metavar='NUMBER',
672)     callback=_validate_occurrence_constraint,
673)     help='includes at least NUMBER digits',
674)     cls=PasswordGenerationOption,
675) )
676) @click.option(
677)     '--space',
678)     metavar='NUMBER',
679)     callback=_validate_occurrence_constraint,
680)     help='includes at least NUMBER spaces',
681)     cls=PasswordGenerationOption,
682) )
683) @click.option(
684)     '--dash',
685)     metavar='NUMBER',
686)     callback=_validate_occurrence_constraint,
687)     help='includes at least NUMBER "-" or "_"',
688)     cls=PasswordGenerationOption,
689) )
690) @click.option(
691)     '--symbol',
692)     metavar='NUMBER',
693)     callback=_validate_occurrence_constraint,
694)     help='includes at least NUMBER symbol chars',
695)     cls=PasswordGenerationOption,
696) )
697) @click.option(
698)     '-n',
699)     '--notes',
700)     'edit_notes',
701)     is_flag=True,
702)     help='spawn an editor to edit notes for SERVICE',
703)     cls=ConfigurationOption,
704) )
705) @click.option(
706)     '-c',
707)     '--config',
708)     'store_config_only',
709)     is_flag=True,
710)     help='saves the given settings for SERVICE or global',
711)     cls=ConfigurationOption,
712) )
713) @click.option(
714)     '-x',
715)     '--delete',
716)     'delete_service_settings',
717)     is_flag=True,
718)     help='deletes settings for SERVICE',
719)     cls=ConfigurationOption,
720) )
721) @click.option(
722)     '--delete-globals',
723)     is_flag=True,
724)     help='deletes the global shared settings',
725)     cls=ConfigurationOption,
726) )
727) @click.option(
728)     '-X',
729)     '--clear',
730)     'clear_all_settings',
731)     is_flag=True,
732)     help='deletes all settings',
733)     cls=ConfigurationOption,
734) )
735) @click.option(
736)     '-e',
737)     '--export',
738)     'export_settings',
739)     metavar='PATH',
740)     type=click.Path(file_okay=True, allow_dash=True, exists=False),
741)     help='export all saved settings into file PATH',
742)     cls=StorageManagementOption,
743) )
744) @click.option(
745)     '-i',
746)     '--import',
747)     'import_settings',
748)     metavar='PATH',
749)     type=click.Path(file_okay=True, allow_dash=True, exists=False),
750)     help='import saved settings from file PATH',
751)     cls=StorageManagementOption,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

752) )
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

753) @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

754) @click.argument('service', required=False)
755) @click.pass_context
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

756) def derivepassphrase(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

757)     ctx: click.Context,
758)     /,
759)     *,
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

760)     service: str | None = None,
761)     use_phrase: bool = False,
762)     use_key: bool = False,
763)     length: int | None = None,
764)     repeat: int | None = None,
765)     lower: int | None = None,
766)     upper: int | None = None,
767)     number: int | None = None,
768)     space: int | None = None,
769)     dash: int | None = None,
770)     symbol: int | None = None,
771)     edit_notes: bool = False,
772)     store_config_only: bool = False,
773)     delete_service_settings: bool = False,
774)     delete_globals: bool = False,
775)     clear_all_settings: bool = False,
776)     export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
777)     import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

778) ) -> None:
779)     """Derive a strong passphrase, deterministically, from a master secret.
780) 
Marco Ricci Fill out README and documen...

Marco Ricci authored 2 months ago

781)     Using a master passphrase or a master SSH key, derive a passphrase
782)     for SERVICE, subject to length, character and character repetition
783)     constraints.  The derivation is cryptographically strong, meaning
784)     that even if a single passphrase is compromised, guessing the master
785)     passphrase or a different service's passphrase is computationally
786)     infeasible.  The derivation is also deterministic, given the same
787)     inputs, thus the resulting passphrase need not be stored explicitly.
788)     The service name and constraints themselves also need not be kept
789)     secret; the latter are usually stored in a world-readable file.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

790) 
791)     If operating on global settings, or importing/exporting settings,
792)     then SERVICE must be omitted.  Otherwise it is required.\f
793) 
794)     This is a [`click`][CLICK]-powered command-line interface function,
795)     and not intended for programmatic use.  Call with arguments
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

796)     `['--help']` to see full documentation of the interface.  (See also
797)     [`click.testing.CliRunner`][] for controlled, programmatic
798)     invocation.)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

799) 
800)     [CLICK]: https://click.palletsprojects.com/
801) 
802)     Parameters:
803)         ctx (click.Context):
804)             The `click` context.
805) 
806)     Other Parameters:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

807)         service:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

808)             A service name.  Required, unless operating on global
809)             settings or importing/exporting settings.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

810)         use_phrase:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

811)             Command-line argument `-p`/`--phrase`.  If given, query the
812)             user for a passphrase instead of an SSH key.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

813)         use_key:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

814)             Command-line argument `-k`/`--key`.  If given, query the
815)             user for an SSH key instead of a passphrase.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

816)         length:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

817)             Command-line argument `-l`/`--length`.  Override the default
818)             length of the generated passphrase.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

819)         repeat:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

820)             Command-line argument `-r`/`--repeat`.  Override the default
821)             repetition limit if positive, or disable the repetition
822)             limit if 0.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

823)         lower:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

824)             Command-line argument `--lower`.  Require a given amount of
825)             ASCII lowercase characters if positive, else forbid ASCII
826)             lowercase characters if 0.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

827)         upper:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

828)             Command-line argument `--upper`.  Same as `lower`, but for
829)             ASCII uppercase characters.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

830)         number:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

831)             Command-line argument `--number`.  Same as `lower`, but for
832)             ASCII digits.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

833)         space:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

834)             Command-line argument `--space`.  Same as `lower`, but for
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

835)             the space character.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

836)         dash:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

837)             Command-line argument `--dash`.  Same as `lower`, but for
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

838)             the hyphen-minus and underscore characters.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

839)         symbol:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

840)             Command-line argument `--symbol`.  Same as `lower`, but for
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

841)             all other ASCII printable characters (except backquote).
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

842)         edit_notes:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

843)             Command-line argument `-n`/`--notes`.  If given, spawn an
844)             editor to edit notes for `service`.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

845)         store_config_only:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

846)             Command-line argument `-c`/`--config`.  If given, saves the
847)             other given settings (`--key`, ..., `--symbol`) to the
848)             configuration file, either specifically for `service` or as
849)             global settings.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

850)         delete_service_settings:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

851)             Command-line argument `-x`/`--delete`.  If given, removes
852)             the settings for `service` from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

853)         delete_globals:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

854)             Command-line argument `--delete-globals`.  If given, removes
855)             the global settings from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

856)         clear_all_settings:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

857)             Command-line argument `-X`/`--clear`.  If given, removes all
858)             settings from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

859)         export_settings:
860)             Command-line argument `-e`/`--export`.  If a file object,
861)             then it must be open for writing and accept `str` inputs.
862)             Otherwise, a filename to open for writing.  Using `-` for
863)             standard output is supported.
864)         import_settings:
865)             Command-line argument `-i`/`--import`.  If a file object, it
866)             must be open for reading and yield `str` values.  Otherwise,
867)             a filename to open for reading.  Using `-` for standard
868)             input is supported.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

869) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

870)     """  # noqa: D301
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

871)     options_in_group: dict[type[click.Option], list[click.Option]] = {}
872)     params_by_str: dict[str, click.Parameter] = {}
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

873)     for param in ctx.command.params:
874)         if isinstance(param, click.Option):
875)             group: type[click.Option]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

876)             match param:
877)                 case PasswordGenerationOption():
878)                     group = PasswordGenerationOption
879)                 case ConfigurationOption():
880)                     group = ConfigurationOption
881)                 case StorageManagementOption():
882)                     group = StorageManagementOption
883)                 case OptionGroupOption():
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

884)                     raise AssertionError(  # noqa: DOC501,TRY003
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

885)                         f'Unknown option group for {param!r}'  # noqa: EM102
886)                     )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

887)                 case _:
888)                     group = click.Option
889)             options_in_group.setdefault(group, []).append(param)
890)         params_by_str[param.human_readable_name] = param
891)         for name in param.opts + param.secondary_opts:
892)             params_by_str[name] = param
893) 
Marco Ricci Fix typing issues in mypy s...

Marco Ricci authored 1 month ago

894)     def is_param_set(param: click.Parameter) -> bool:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

895)         return bool(ctx.params.get(param.human_readable_name))
896) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

897)     def check_incompatible_options(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

898)         param: click.Parameter | str,
899)         *incompatible: click.Parameter | str,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

900)     ) -> None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

901)         if isinstance(param, str):
902)             param = params_by_str[param]
903)         assert isinstance(param, click.Parameter)
904)         if not is_param_set(param):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

905)             return
906)         for other in incompatible:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

907)             if isinstance(other, str):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

908)                 other = params_by_str[other]  # noqa: PLW2901
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

909)             assert isinstance(other, click.Parameter)
910)             if other != param and is_param_set(other):
911)                 opt_str = param.opts[0]
912)                 other_str = other.opts[0]
913)                 raise click.BadOptionUsage(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

914)                     opt_str, f'mutually exclusive with {other_str}', ctx=ctx
915)                 )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

916) 
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

917)     def err(msg: str) -> NoReturn:
918)         click.echo(f'{PROG_NAME}: {msg}', err=True)
919)         ctx.exit(1)
920) 
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

921)     def get_config() -> _types.VaultConfig:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

922)         try:
923)             return _load_config()
924)         except FileNotFoundError:
925)             return {'services': {}}
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

926)         except OSError as e:
927)             err(f'Cannot load config: {e.strerror}: {e.filename!r}')
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

928)         except Exception as e:  # noqa: BLE001
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

929)             err(f'Cannot load config: {e}')
930) 
931)     def put_config(config: _types.VaultConfig, /) -> None:
932)         try:
933)             _save_config(config)
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

934)         except OSError as exc:
935)             err(f'Cannot store config: {exc.strerror}: {exc.filename!r}')
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

936)         except Exception as exc:  # noqa: BLE001
937)             err(f'Cannot store config: {exc}')
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

938) 
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

939)     configuration: _types.VaultConfig
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

940) 
941)     check_incompatible_options('--phrase', '--key')
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

942)     for group in (ConfigurationOption, StorageManagementOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

943)         for opt in options_in_group[group]:
944)             if opt != params_by_str['--config']:
945)                 check_incompatible_options(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

946)                     opt, *options_in_group[PasswordGenerationOption]
947)                 )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

948) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

949)     for group in (ConfigurationOption, StorageManagementOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

950)         for opt in options_in_group[group]:
951)             check_incompatible_options(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

952)                 opt,
953)                 *options_in_group[ConfigurationOption],
954)                 *options_in_group[StorageManagementOption],
955)             )
956)     sv_options = options_in_group[PasswordGenerationOption] + [
957)         params_by_str['--notes'],
958)         params_by_str['--delete'],
959)     ]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

960)     sv_options.remove(params_by_str['--key'])
961)     sv_options.remove(params_by_str['--phrase'])
962)     for param in sv_options:
963)         if is_param_set(param) and not service:
964)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

965)             msg = f'{opt_str} requires a SERVICE'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

966)             raise click.UsageError(msg)  # noqa: DOC501
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

967)     for param in [params_by_str['--key'], params_by_str['--phrase']]:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

968)         if is_param_set(param) and not (
969)             service or is_param_set(params_by_str['--config'])
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

970)         ):
971)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

972)             msg = f'{opt_str} requires a SERVICE or --config'
973)             raise click.UsageError(msg)
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

974)     no_sv_options = [
975)         params_by_str['--delete-globals'],
976)         params_by_str['--clear'],
977)         *options_in_group[StorageManagementOption],
978)     ]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

979)     for param in no_sv_options:
980)         if is_param_set(param) and service:
981)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

982)             msg = f'{opt_str} does not take a SERVICE argument'
983)             raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

984) 
985)     if edit_notes:
986)         assert service is not None
987)         configuration = get_config()
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

988)         text = DEFAULT_NOTES_TEMPLATE + configuration['services'].get(
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

989)             service, cast(_types.VaultConfigServicesSettings, {})
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

990)         ).get('notes', '')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

991)         notes_value = click.edit(text=text)
992)         if notes_value is not None:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

993)             notes_lines = collections.deque(notes_value.splitlines(True))  # noqa: FBT003
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

994)             while notes_lines:
995)                 line = notes_lines.popleft()
996)                 if line.startswith(DEFAULT_NOTES_MARKER):
997)                     notes_value = ''.join(notes_lines)
998)                     break
999)             else:
1000)                 if not notes_value.strip():
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1001)                     err('Not saving new notes: user aborted request')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1002)             configuration['services'].setdefault(service, {})['notes'] = (
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1003)                 notes_value.strip('\n')
1004)             )
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1005)             put_config(configuration)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1006)     elif delete_service_settings:
1007)         assert service is not None
1008)         configuration = get_config()
1009)         if service in configuration['services']:
1010)             del configuration['services'][service]
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1011)             put_config(configuration)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1012)     elif delete_globals:
1013)         configuration = get_config()
1014)         if 'global' in configuration:
1015)             del configuration['global']
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1016)             put_config(configuration)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1017)     elif clear_all_settings:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1018)         put_config({'services': {}})
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1019)     elif import_settings:
1020)         try:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

1021)             # TODO(the-13th-letter): keep track of auto-close; try
1022)             # os.dup if feasible
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1023)             infile = (
1024)                 cast(TextIO, import_settings)
1025)                 if hasattr(import_settings, 'close')
1026)                 else click.open_file(os.fspath(import_settings), 'rt')
1027)             )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1028)             with infile:
1029)                 maybe_config = json.load(infile)
1030)         except json.JSONDecodeError as e:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1031)             err(f'Cannot load config: cannot decode JSON: {e}')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1032)         except OSError as e:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1033)             err(f'Cannot load config: {e.strerror}: {e.filename!r}')
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

1034)         if _types.is_vault_config(maybe_config):
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

1035)             form = cast(
1036)                 Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
1037)                 maybe_config.get('global', {}).get(
1038)                     'unicode_normalization_form', 'NFC'
1039)                 ),
1040)             )
1041)             assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
1042)             _check_for_misleading_passphrase(
1043)                 ('global',),
1044)                 cast(dict[str, Any], maybe_config.get('global', {})),
1045)                 form=form,
1046)             )
1047)             for key, value in maybe_config['services'].items():
1048)                 _check_for_misleading_passphrase(
1049)                     ('services', key),
1050)                     cast(dict[str, Any], value),
1051)                     form=form,
1052)                 )
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1053)             put_config(maybe_config)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1054)         else:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1055)             err(f'Cannot load config: {_INVALID_VAULT_CONFIG}')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1056)     elif export_settings:
1057)         configuration = get_config()
1058)         try:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

1059)             # TODO(the-13th-letter): keep track of auto-close; try
1060)             # os.dup if feasible
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1061)             outfile = (
1062)                 cast(TextIO, export_settings)
1063)                 if hasattr(export_settings, 'close')
1064)                 else click.open_file(os.fspath(export_settings), 'wt')
1065)             )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1066)             with outfile:
1067)                 json.dump(configuration, outfile)
1068)         except OSError as e:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1069)             err(f'Cannot store config: {e.strerror}: {e.filename!r}')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1070)     else:
1071)         configuration = get_config()
1072)         # This block could be type checked more stringently, but this
1073)         # would probably involve a lot of code repetition.  Since we
1074)         # have a type guarding function anyway, assert that we didn't
1075)         # make any mistakes at the end instead.
1076)         global_keys = {'key', 'phrase'}
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1077)         service_keys = {
1078)             'key',
1079)             'phrase',
1080)             'length',
1081)             'repeat',
1082)             'lower',
1083)             'upper',
1084)             'number',
1085)             'space',
1086)             'dash',
1087)             'symbol',
1088)         }
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1089)         settings: collections.ChainMap[str, Any] = collections.ChainMap(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1090)             {
1091)                 k: v
1092)                 for k, v in locals().items()
1093)                 if k in service_keys and v is not None
1094)             },
1095)             cast(
1096)                 dict[str, Any],
1097)                 configuration['services'].get(service or '', {}),
1098)             ),
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1099)             {},
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1100)             cast(dict[str, Any], configuration.get('global', {})),
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1101)         )
1102)         if use_key:
1103)             try:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1104)                 key = base64.standard_b64encode(_select_ssh_key()).decode(
1105)                     'ASCII'
1106)                 )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1107)             except IndexError:
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1108)                 err('No valid SSH key selected')
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

1109)             except KeyError:
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1110)                 err('Cannot find running SSH agent; check SSH_AUTH_SOCK')
Marco Ricci Document and handle other e...

Marco Ricci authored 1 month ago

1111)             except OSError as e:
1112)                 err(
1113)                     f'Cannot connect to SSH agent: {e.strerror}: '
1114)                     f'{e.filename!r}'
1115)                 )
Marco Ricci Add a specific error class...

Marco Ricci authored 1 month ago

1116)             except (
1117)                 LookupError,
1118)                 RuntimeError,
1119)                 ssh_agent.SSHAgentFailedError,
1120)             ) as e:
Marco Ricci Use better error message ha...

Marco Ricci authored 1 month ago

1121)                 err(str(e))
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1122)         elif use_phrase:
1123)             maybe_phrase = _prompt_for_passphrase()
1124)             if not maybe_phrase:
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1125)                 err('No passphrase given')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1126)             else:
1127)                 phrase = maybe_phrase
1128)         if store_config_only:
1129)             view: collections.ChainMap[str, Any]
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1130)             view = (
1131)                 collections.ChainMap(*settings.maps[:2])
1132)                 if service
1133)                 else settings.parents.parents
1134)             )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1135)             if use_key:
1136)                 view['key'] = key
1137)                 for m in view.maps:
1138)                     m.pop('phrase', '')
1139)             elif use_phrase:
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

1140)                 _check_for_misleading_passphrase(
1141)                     ('services', service) if service else ('global',),
1142)                     {'phrase': phrase},
1143)                 )
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1144)                 view['phrase'] = phrase
1145)                 for m in view.maps:
1146)                     m.pop('key', '')
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

1147)             if not view.maps[0]:
1148)                 settings_type = 'service' if service else 'global'
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1149)                 msg = (
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1150)                     f'Cannot update {settings_type} settings without '
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1151)                     f'actual settings'
1152)                 )
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

1153)                 raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1154)             if service:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1155)                 configuration['services'].setdefault(service, {}).update(view)  # type: ignore[typeddict-item]
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1156)             else:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1157)                 configuration.setdefault('global', {}).update(view)  # type: ignore[typeddict-item]
Marco Ricci Consolidate `types` submodu...

Marco Ricci authored 1 month ago

1158)             assert _types.is_vault_config(
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1159)                 configuration
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1160)             ), f'Invalid vault configuration: {configuration!r}'
Marco Ricci Fix error bubbling in outda...

Marco Ricci authored 1 month ago

1161)             put_config(configuration)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1162)         else:
1163)             if not service:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

1164)                 msg = 'SERVICE is required'
1165)                 raise click.UsageError(msg)
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1166)             kwargs: dict[str, Any] = {
1167)                 k: v
1168)                 for k, v in settings.items()
1169)                 if k in service_keys and v is not None
1170)             }
1171) 
Marco Ricci Shift misplaced local function

Marco Ricci authored 1 month ago

1172)             def key_to_phrase(
1173)                 key: str | bytes | bytearray,
1174)             ) -> bytes | bytearray:
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

1175)                 return vault.Vault.phrase_from_key(
Marco Ricci Shift misplaced local function

Marco Ricci authored 1 month ago

1176)                     base64.standard_b64decode(key)
1177)                 )
1178) 
Marco Ricci Allow all textual strings,...

Marco Ricci authored 2 weeks ago

1179)             if use_phrase:
1180)                 form = cast(
1181)                     Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
1182)                     configuration.get('global', {}).get(
1183)                         'unicode_normalization_form', 'NFC'
1184)                     ),
1185)                 )
1186)                 assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
1187)                 _check_for_misleading_passphrase(
1188)                     ('interactive',), {'phrase': phrase}, form=form
1189)                 )
1190) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1191)             # If either --key or --phrase are given, use that setting.
1192)             # Otherwise, if both key and phrase are set in the config,
1193)             # one must be global (ignore it) and one must be
1194)             # service-specific (use that one). Otherwise, if only one of
1195)             # key and phrase is set in the config, use that one.  In all
1196)             # these above cases, set the phrase via
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

1197)             # derivepassphrase.vault.Vault.phrase_from_key if a key is
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1198)             # given. Finally, if nothing is set, error out.
1199)             if use_key or use_phrase:
Marco Ricci Avoid crashing when overrid...

Marco Ricci authored 1 month ago

1200)                 kwargs['phrase'] = key_to_phrase(key) if use_key else phrase
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1201)             elif kwargs.get('phrase') and kwargs.get('key'):
1202)                 if any('key' in m for m in settings.maps[:2]):
Marco Ricci Avoid crashing when overrid...

Marco Ricci authored 1 month ago

1203)                     kwargs['phrase'] = key_to_phrase(kwargs['key'])
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1204)             elif kwargs.get('key'):
Marco Ricci Avoid crashing when overrid...

Marco Ricci authored 1 month ago

1205)                 kwargs['phrase'] = key_to_phrase(kwargs['key'])
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1206)             elif kwargs.get('phrase'):
1207)                 pass
1208)             else:
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1209)                 msg = (
Marco Ricci Fix error message capitaliz...

Marco Ricci authored 1 month ago

1210)                     'No passphrase or key given on command-line '
Marco Ricci Reformat everything with ruff

Marco Ricci authored 1 month ago

1211)                     'or in configuration'
1212)                 )
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

1213)                 raise click.UsageError(msg)
Marco Ricci Avoid crashing when overrid...

Marco Ricci authored 1 month ago

1214)             kwargs.pop('key', '')
Marco Ricci Move `sequin` and `ssh_agen...

Marco Ricci authored 1 month ago

1215)             result = vault.Vault(**kwargs).generate(service)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

1216)             click.echo(result.decode('ASCII'))