1"""This modules define the actual display implementations used in Certbot"""
2import logging
3import sys
4from typing import Any
5from typing import Iterable
6from typing import List
7from typing import Optional
8from typing import TextIO
9from typing import Tuple
10from typing import TypeVar
11from typing import Union
12
13import zope.component
14import zope.interface
15
16from certbot import errors
17from certbot import interfaces
18from certbot._internal import constants
19from certbot._internal.display import completer
20from certbot._internal.display import util
21from certbot.compat import os
22
23logger = logging.getLogger(__name__)
24
25# Display exit codes
26OK = "ok"
27"""Display exit code indicating user acceptance."""
28
29CANCEL = "cancel"
30"""Display exit code for a user canceling the display."""
31
32# Display constants
33SIDE_FRAME = ("- " * 39) + "-"
34"""Display boundary (alternates spaces, so when copy-pasted, markdown doesn't interpret
35it as a heading)"""
36
37# This class holds the global state of the display service. Using this class
38# eliminates potential gotchas that exist if self.display was just a global
39# variable. In particular, in functions `_DISPLAY = <value>` would create a
40# local variable unless the programmer remembered to use the `global` keyword.
41# Adding a level of indirection causes the lookup of the global _DisplayService
42# object to happen first avoiding this potential bug.
43class _DisplayService:
44    def __init__(self) -> None:
45        self.display: Optional[Union[FileDisplay, NoninteractiveDisplay]] = None
46
47
48_SERVICE = _DisplayService()
49
50T = TypeVar("T")
51
52
53# This use of IDisplay can be removed when this class is no longer accessible
54# through the public API in certbot.display.util.
55@zope.interface.implementer(interfaces.IDisplay)
56class FileDisplay:
57    """File-based display."""
58    # see https://github.com/certbot/certbot/issues/3915
59
60    def __init__(self, outfile: TextIO, force_interactive: bool) -> None:
61        super().__init__()
62        self.outfile = outfile
63        self.force_interactive = force_interactive
64        self.skipped_interaction = False
65
66    def notification(self, message: str, pause: bool = True, wrap: bool = True,
67                     force_interactive: bool = False, decorate: bool = True) -> None:
68        """Displays a notification and waits for user acceptance.
69
70        :param str message: Message to display
71        :param bool pause: Whether or not the program should pause for the
72            user's confirmation
73        :param bool wrap: Whether or not the application should wrap text
74        :param bool force_interactive: True if it's safe to prompt the user
75            because it won't cause any workflow regressions
76        :param bool decorate: Whether to surround the message with a
77            decorated frame
78
79        """
80        if wrap:
81            message = util.wrap_lines(message)
82
83        logger.debug("Notifying user: %s", message)
84
85        self.outfile.write(
86            (("{line}{frame}{line}" if decorate else "") +
87             "{msg}{line}" +
88             ("{frame}{line}" if decorate else ""))
89                .format(line=os.linesep, frame=SIDE_FRAME, msg=message)
90        )
91        self.outfile.flush()
92
93        if pause:
94            if self._can_interact(force_interactive):
95                util.input_with_timeout("Press Enter to Continue")
96            else:
97                logger.debug("Not pausing for user confirmation")
98
99    def menu(self, message: str, choices: Union[List[Tuple[str, str]], List[str]],
100             ok_label: Optional[str] = None, cancel_label: Optional[str] = None,  # pylint: disable=unused-argument
101             help_label: Optional[str] = None, default: Optional[int] = None,  # pylint: disable=unused-argument
102             cli_flag: Optional[str] = None, force_interactive: bool = False,
103             **unused_kwargs: Any) -> Tuple[str, int]:
104        """Display a menu.
105
106        .. todo:: This doesn't enable the help label/button (I wasn't sold on
107           any interface I came up with for this). It would be a nice feature
108
109        :param str message: title of menu
110        :param choices: Menu lines, len must be > 0
111        :type choices: list of tuples (tag, item) or
112            list of descriptions (tags will be enumerated)
113        :param default: default value to return (if one exists)
114        :param str cli_flag: option used to set this value with the CLI
115        :param bool force_interactive: True if it's safe to prompt the user
116            because it won't cause any workflow regressions
117
118        :returns: tuple of (`code`, `index`) where
119            `code` - str display exit code
120            `index` - int index of the user's selection
121
122        :rtype: tuple
123
124        """
125        return_default = self._return_default(message, default, cli_flag, force_interactive)
126        if return_default is not None:
127            return OK, return_default
128
129        self._print_menu(message, choices)
130
131        code, selection = self._get_valid_int_ans(len(choices))
132
133        return code, selection - 1
134
135    def input(self, message: str, default: Optional[str] = None, cli_flag: Optional[str] = None,
136              force_interactive: bool = False, **unused_kwargs: Any) -> Tuple[str, str]:
137        """Accept input from the user.
138
139        :param str message: message to display to the user
140        :param default: default value to return (if one exists)
141        :param str cli_flag: option used to set this value with the CLI
142        :param bool force_interactive: True if it's safe to prompt the user
143            because it won't cause any workflow regressions
144
145        :returns: tuple of (`code`, `input`) where
146            `code` - str display exit code
147            `input` - str of the user's input
148        :rtype: tuple
149
150        """
151        return_default = self._return_default(message, default, cli_flag, force_interactive)
152        if return_default is not None:
153            return OK, return_default
154
155        # Trailing space must be added outside of util.wrap_lines to
156        # be preserved
157        message = util.wrap_lines("%s (Enter 'c' to cancel):" % message) + " "
158        ans = util.input_with_timeout(message)
159
160        if ans in ("c", "C"):
161            return CANCEL, "-1"
162        return OK, ans
163
164    def yesno(self, message: str, yes_label: str = "Yes", no_label: str = "No",
165              default: Optional[bool] = None, cli_flag: Optional[str] = None,
166              force_interactive: bool = False, **unused_kwargs: Any) -> bool:
167        """Query the user with a yes/no question.
168
169        Yes and No label must begin with different letters, and must contain at
170        least one letter each.
171
172        :param str message: question for the user
173        :param str yes_label: Label of the "Yes" parameter
174        :param str no_label: Label of the "No" parameter
175        :param default: default value to return (if one exists)
176        :param str cli_flag: option used to set this value with the CLI
177        :param bool force_interactive: True if it's safe to prompt the user
178            because it won't cause any workflow regressions
179
180        :returns: True for "Yes", False for "No"
181        :rtype: bool
182
183        """
184        return_default = self._return_default(message, default, cli_flag, force_interactive)
185        if return_default is not None:
186            return return_default
187
188        message = util.wrap_lines(message)
189
190        self.outfile.write("{0}{frame}{msg}{0}{frame}".format(
191            os.linesep, frame=SIDE_FRAME + os.linesep, msg=message))
192        self.outfile.flush()
193
194        while True:
195            ans = util.input_with_timeout("{yes}/{no}: ".format(
196                yes=util.parens_around_char(yes_label),
197                no=util.parens_around_char(no_label)))
198
199            # Couldn't get pylint indentation right with elif
200            # elif doesn't matter in this situation
201            if (ans.startswith(yes_label[0].lower()) or
202                ans.startswith(yes_label[0].upper())):
203                return True
204            if (ans.startswith(no_label[0].lower()) or
205                ans.startswith(no_label[0].upper())):
206                return False
207
208    def checklist(self, message: str, tags: List[str], default: Optional[List[str]] = None,
209                  cli_flag: Optional[str] = None, force_interactive: bool = False,
210                  **unused_kwargs: Any) -> Tuple[str, List[str]]:
211        """Display a checklist.
212
213        :param str message: Message to display to user
214        :param list tags: `str` tags to select, len(tags) > 0
215        :param default: default value to return (if one exists)
216        :param str cli_flag: option used to set this value with the CLI
217        :param bool force_interactive: True if it's safe to prompt the user
218            because it won't cause any workflow regressions
219
220        :returns: tuple of (`code`, `tags`) where
221            `code` - str display exit code
222            `tags` - list of selected tags
223        :rtype: tuple
224
225        """
226        return_default = self._return_default(message, default, cli_flag, force_interactive)
227        if return_default is not None:
228            return OK, return_default
229
230        while True:
231            self._print_menu(message, tags)
232
233            code, ans = self.input("Select the appropriate numbers separated "
234                                   "by commas and/or spaces, or leave input "
235                                   "blank to select all options shown",
236                                   force_interactive=True)
237
238            if code == OK:
239                if not ans.strip():
240                    ans = " ".join(str(x) for x in range(1, len(tags)+1))
241                indices = util.separate_list_input(ans)
242                selected_tags = self._scrub_checklist_input(indices, tags)
243                if selected_tags:
244                    return code, selected_tags
245                self.outfile.write(
246                    "** Error - Invalid selection **%s" % os.linesep)
247                self.outfile.flush()
248            else:
249                return code, []
250
251    def _return_default(self, prompt: str, default: Optional[T],
252                        cli_flag: Optional[str], force_interactive: bool) -> Optional[T]:
253        """Should we return the default instead of prompting the user?
254
255        :param str prompt: prompt for the user
256        :param T default: default answer to prompt
257        :param str cli_flag: command line option for setting an answer
258            to this question
259        :param bool force_interactive: if interactivity is forced
260
261        :returns: The default value if we should return it else `None`
262        :rtype: T or `None`
263
264        """
265        # assert_valid_call(prompt, default, cli_flag, force_interactive)
266        if self._can_interact(force_interactive):
267            return None
268        if default is None:
269            msg = "Unable to get an answer for the question:\n{0}".format(prompt)
270            if cli_flag:
271                msg += (
272                    "\nYou can provide an answer on the "
273                    "command line with the {0} flag.".format(cli_flag))
274            raise errors.Error(msg)
275        logger.debug(
276            "Falling back to default %s for the prompt:\n%s",
277            default, prompt)
278        return default
279
280    def _can_interact(self, force_interactive: bool) -> bool:
281        """Can we safely interact with the user?
282
283        :param bool force_interactive: if interactivity is forced
284
285        :returns: True if the display can interact with the user
286        :rtype: bool
287
288        """
289        if (self.force_interactive or force_interactive or
290            sys.stdin.isatty() and self.outfile.isatty()):
291            return True
292        if not self.skipped_interaction:
293            logger.warning(
294                "Skipped user interaction because Certbot doesn't appear to "
295                "be running in a terminal. You should probably include "
296                "--non-interactive or %s on the command line.",
297                constants.FORCE_INTERACTIVE_FLAG)
298            self.skipped_interaction = True
299        return False
300
301    def directory_select(self, message: str, default: Optional[str] = None,
302                         cli_flag: Optional[str] = None, force_interactive: bool = False,
303                         **unused_kwargs: Any) -> Tuple[str, str]:
304        """Display a directory selection screen.
305
306        :param str message: prompt to give the user
307        :param default: default value to return (if one exists)
308        :param str cli_flag: option used to set this value with the CLI
309        :param bool force_interactive: True if it's safe to prompt the user
310            because it won't cause any workflow regressions
311
312        :returns: tuple of the form (`code`, `string`) where
313            `code` - display exit code
314            `string` - input entered by the user
315
316        """
317        with completer.Completer():
318            return self.input(message, default, cli_flag, force_interactive)
319
320    def _scrub_checklist_input(self, indices: Iterable[Union[str, int]],
321                               tags: List[str]) -> List[str]:
322        """Validate input and transform indices to appropriate tags.
323
324        :param list indices: input
325        :param list tags: Original tags of the checklist
326
327        :returns: valid tags the user selected
328        :rtype: :class:`list` of :class:`str`
329
330        """
331        # They should all be of type int
332        try:
333            indices_int = [int(index) for index in indices]
334        except ValueError:
335            return []
336
337        # Remove duplicates
338        indices_int = list(set(indices_int))
339
340        # Check all input is within range
341        for index in indices_int:
342            if index < 1 or index > len(tags):
343                return []
344        # Transform indices_int to appropriate tags
345        return [tags[index - 1] for index in indices_int]
346
347    def _print_menu(self, message: str,
348                    choices: Union[List[Tuple[str, str]], List[str]]) -> None:
349        """Print a menu on the screen.
350
351        :param str message: title of menu
352        :param choices: Menu lines
353        :type choices: list of tuples (tag, item) or
354            list of descriptions (tags will be enumerated)
355
356        """
357        # Can take either tuples or single items in choices list
358        if choices and isinstance(choices[0], tuple):
359            choices = ["%s - %s" % (c[0], c[1]) for c in choices]
360
361        # Write out the message to the user
362        self.outfile.write(
363            "{new}{msg}{new}".format(new=os.linesep, msg=message))
364        self.outfile.write(SIDE_FRAME + os.linesep)
365
366        # Write out the menu choices
367        for i, desc in enumerate(choices, 1):
368            msg = "{num}: {desc}".format(num=i, desc=desc)
369            self.outfile.write(util.wrap_lines(msg))
370
371            # Keep this outside of the textwrap
372            self.outfile.write(os.linesep)
373
374        self.outfile.write(SIDE_FRAME + os.linesep)
375        self.outfile.flush()
376
377    def _get_valid_int_ans(self, max_: int) -> Tuple[str, int]:
378        """Get a numerical selection.
379
380        :param int max: The maximum entry (len of choices), must be positive
381
382        :returns: tuple of the form (`code`, `selection`) where
383            `code` - str display exit code ('ok' or cancel')
384            `selection` - int user's selection
385        :rtype: tuple
386
387        """
388        selection = -1
389        if max_ > 1:
390            input_msg = ("Select the appropriate number "
391                         "[1-{max_}] then [enter] (press 'c' to "
392                         "cancel): ".format(max_=max_))
393        else:
394            input_msg = ("Press 1 [enter] to confirm the selection "
395                         "(press 'c' to cancel): ")
396        while selection < 1:
397            ans = util.input_with_timeout(input_msg)
398            if ans.startswith("c") or ans.startswith("C"):
399                return CANCEL, -1
400            try:
401                selection = int(ans)
402                if selection < 1 or selection > max_:
403                    selection = -1
404                    raise ValueError
405
406            except ValueError:
407                self.outfile.write(
408                    "{0}** Invalid input **{0}".format(os.linesep))
409                self.outfile.flush()
410
411        return OK, selection
412
413
414# This use of IDisplay can be removed when this class is no longer accessible
415# through the public API in certbot.display.util.
416@zope.interface.implementer(interfaces.IDisplay)
417class NoninteractiveDisplay:
418    """A display utility implementation that never asks for interactive user input"""
419
420    def __init__(self, outfile: TextIO, *unused_args: Any, **unused_kwargs: Any) -> None:
421        super().__init__()
422        self.outfile = outfile
423
424    def _interaction_fail(self, message: str, cli_flag: Optional[str],
425                          extra: str = "") -> errors.MissingCommandlineFlag:
426        """Return error to raise in case of an attempt to interact in noninteractive mode"""
427        msg = "Missing command line flag or config entry for this setting:\n"
428        msg += message
429        if extra:
430            msg += "\n" + extra
431        if cli_flag:
432            msg += "\n\n(You can set this with the {0} flag)".format(cli_flag)
433        return errors.MissingCommandlineFlag(msg)
434
435    def notification(self, message: str, pause: bool = False, wrap: bool = True,  # pylint: disable=unused-argument
436                     decorate: bool = True, **unused_kwargs: Any) -> None:
437        """Displays a notification without waiting for user acceptance.
438
439        :param str message: Message to display to stdout
440        :param bool pause: The NoninteractiveDisplay waits for no keyboard
441        :param bool wrap: Whether or not the application should wrap text
442        :param bool decorate: Whether to apply a decorated frame to the message
443
444        """
445        if wrap:
446            message = util.wrap_lines(message)
447
448        logger.debug("Notifying user: %s", message)
449
450        self.outfile.write(
451            (("{line}{frame}{line}" if decorate else "") +
452             "{msg}{line}" +
453             ("{frame}{line}" if decorate else ""))
454                .format(line=os.linesep, frame=SIDE_FRAME, msg=message)
455        )
456        self.outfile.flush()
457
458    def menu(self, message: str, choices: Union[List[Tuple[str, str]], List[str]],
459             ok_label: Optional[str] = None, cancel_label: Optional[str] = None,
460             help_label: Optional[str] = None, default: Optional[int] = None,
461             cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, int]:
462        # pylint: disable=unused-argument
463        """Avoid displaying a menu.
464
465        :param str message: title of menu
466        :param choices: Menu lines, len must be > 0
467        :type choices: list of tuples (tag, item) or
468            list of descriptions (tags will be enumerated)
469        :param int default: the default choice
470        :param dict kwargs: absorbs various irrelevant labelling arguments
471
472        :returns: tuple of (`code`, `index`) where
473            `code` - str display exit code
474            `index` - int index of the user's selection
475        :rtype: tuple
476        :raises errors.MissingCommandlineFlag: if there was no default
477
478        """
479        if default is None:
480            raise self._interaction_fail(message, cli_flag, "Choices: " + repr(choices))
481
482        return OK, default
483
484    def input(self, message: str, default: Optional[str] = None, cli_flag: Optional[str] = None,
485              **unused_kwargs: Any) -> Tuple[str, str]:
486        """Accept input from the user.
487
488        :param str message: message to display to the user
489
490        :returns: tuple of (`code`, `input`) where
491            `code` - str display exit code
492            `input` - str of the user's input
493        :rtype: tuple
494        :raises errors.MissingCommandlineFlag: if there was no default
495
496        """
497        if default is None:
498            raise self._interaction_fail(message, cli_flag)
499        return OK, default
500
501    def yesno(self, message: str, yes_label: Optional[str] = None, no_label: Optional[str] = None,  # pylint: disable=unused-argument
502              default: Optional[bool] = None, cli_flag: Optional[str] = None,
503              **unused_kwargs: Any) -> bool:
504        """Decide Yes or No, without asking anybody
505
506        :param str message: question for the user
507        :param dict kwargs: absorbs yes_label, no_label
508
509        :raises errors.MissingCommandlineFlag: if there was no default
510        :returns: True for "Yes", False for "No"
511        :rtype: bool
512
513        """
514        if default is None:
515            raise self._interaction_fail(message, cli_flag)
516        return default
517
518    def checklist(self, message: str, tags: Iterable[str], default: Optional[List[str]] = None,
519                  cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, List[str]]:
520        """Display a checklist.
521
522        :param str message: Message to display to user
523        :param list tags: `str` tags to select, len(tags) > 0
524        :param dict kwargs: absorbs default_status arg
525
526        :returns: tuple of (`code`, `tags`) where
527            `code` - str display exit code
528            `tags` - list of selected tags
529        :rtype: tuple
530
531        """
532        if default is None:
533            raise self._interaction_fail(message, cli_flag, "? ".join(tags) + "?")
534        return OK, default
535
536    def directory_select(self, message: str, default: Optional[str] = None,
537                         cli_flag: Optional[str] = None, **unused_kwargs: Any) -> Tuple[str, str]:
538        """Simulate prompting the user for a directory.
539
540        This function returns default if it is not ``None``, otherwise,
541        an exception is raised explaining the problem. If cli_flag is
542        not ``None``, the error message will include the flag that can
543        be used to set this value with the CLI.
544
545        :param str message: prompt to give the user
546        :param default: default value to return (if one exists)
547        :param str cli_flag: option used to set this value with the CLI
548
549        :returns: tuple of the form (`code`, `string`) where
550            `code` - int display exit code
551            `string` - input entered by the user
552
553        """
554        return self.input(message, default, cli_flag)
555
556
557def get_display() -> Union[FileDisplay, NoninteractiveDisplay]:
558    """Get the display utility.
559
560    :return: the display utility
561    :rtype: Union[FileDisplay, NoninteractiveDisplay]
562    :raise: ValueError if the display utility is not configured yet.
563
564    """
565    if not _SERVICE.display:
566        raise ValueError("This function was called too early in Certbot's execution "
567                         "as the display utility hasn't been configured yet.")
568    return _SERVICE.display
569
570
571def set_display(display: Union[FileDisplay, NoninteractiveDisplay]) -> None:
572    """Set the display service.
573
574    :param Union[FileDisplay, NoninteractiveDisplay] display: the display service
575
576    """
577    # This call is done only for retro-compatibility purposes.
578    # TODO: Remove this call once zope dependencies are removed from Certbot.
579    zope.component.provideUtility(display, interfaces.IDisplay)
580
581    _SERVICE.display = display
582