# Issue #1292: Problem with a different sudo message

**GitHub Issue:** https://github.com/pyinfra-dev/pyinfra/issues/1292
**Reporter:** @foozzi
**Assignee:** @Fizzadar
**Labels:** connectors
**Status:** Open

## Issue Summary

The user reports that pyinfra cannot handle sudo password prompts that differ from the expected format. Specifically, when running commands like `makepkg` (which cannot run under sudo) that subsequently ask for a sudo password, the prompt appears as:

```
[sudo] password for user1:
```

However, pyinfra is hardcoded to look for only one specific sudo message format:

```
sudo: a password is required
```

This causes pyinfra to fail when:
1. Running tools like `yay` (AUR helper)
2. Building packages from source on Arch Linux
3. Any situation where a non-sudo command internally invokes sudo with a different prompt format

## Root Cause Analysis

### Location of the Problem

The issue is in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/util.py`, specifically the `execute_command_with_sudo_retry` function (lines 197-217):

```python
def execute_command_with_sudo_retry(
    host: "Host",
    command_arguments: "ConnectorArguments",
    execute_command: Callable[..., tuple[int, CommandOutput]],
) -> tuple[int, CommandOutput]:
    return_code, output = execute_command()

    # If we failed look for a sudo password prompt line and re-submit using the sudo password. Look
    # at all lines here in case anything else gets printed, eg in:
    # https://github.com/pyinfra-dev/pyinfra/issues/1292
    if return_code != 0 and output and output.combined_lines:
        for line in reversed(output.combined_lines):
            if line.line.strip() == "sudo: a password is required":
                # If we need a password, ask the user for it and attach to the host
                # internal connector data for use when executing future commands.
                sudo_password = getpass("{0}sudo password: ".format(host.print_prefix))
                host.connector_data["prompted_sudo_password"] = sudo_password
                return_code, output = execute_command()
                break

    return return_code, output
```

### The Problem

1. **Exact string matching**: The code uses exact string matching (`line.line.strip() == "sudo: a password is required"`) instead of pattern matching.

2. **Single prompt format**: Only the `sudo: a password is required` message (which is the sudo `-n` non-interactive mode failure message) is recognized.

3. **Missing common sudo prompts**: The code does not handle the standard interactive sudo prompt format `[sudo] password for <username>:` which is used when sudo is invoked interactively from within another command.

### Common Sudo Password Prompt Formats

Different systems and scenarios produce different sudo prompts:

1. **Non-interactive sudo failure (current handling)**:
   - `sudo: a password is required`

2. **Standard interactive sudo prompt (NOT handled)**:
   - `[sudo] password for username:`
   - `[sudo] password for username: ` (with trailing space)

3. **BSD/macOS sudo prompts (NOT handled)**:
   - `Password:` (simple format)

4. **Custom sudo prompts (NOT handled)**:
   - Administrators can customize the prompt via `/etc/sudoers` or `sudo -p`

### Related Code Flow

1. When `_sudo=True` is passed to a command, `make_unix_command` in `util.py` wraps the command with sudo flags.
2. If no `_sudo_password` is set, sudo is called with `-n` (non-interactive mode), which outputs `sudo: a password is required` on failure.
3. `execute_command_with_sudo_retry` catches this specific message and prompts the user.
4. However, when a non-sudo command (like `makepkg`) internally calls sudo, it uses the standard interactive prompt which is not caught.

### Maintainer Comment

From @Fizzadar's comment on the issue:
> The line is showing (https://github.com/pyinfra-dev/pyinfra/blob/3.x/pyinfra/connectors/util.py#L203) but pyinfra is looking at the last line, which here is the `WARNING` line. Changing pyinfra to look at all lines (from last to first) is probably the best option here.

This suggests there may have been additional output after the sudo prompt (like a WARNING line) that was causing the issue. The code has since been updated to iterate through all lines in reverse order, but the fundamental issue of only matching one specific string remains.

## Proposed Fix

### Option 1: Add Multiple Pattern Matching (Recommended)

Modify `execute_command_with_sudo_retry` to recognize multiple common sudo prompt patterns:

```python
import re

# Patterns for recognizing sudo password prompts
SUDO_PASSWORD_PATTERNS = [
    r"^sudo: a password is required$",  # Non-interactive mode failure
    r"^\[sudo\] password for .+:$",     # Standard interactive prompt
    r"^Password:$",                       # Simple BSD/macOS style
    r"^.+password for .+:$",             # Generic password prompt
]

def _is_sudo_password_prompt(line: str) -> bool:
    """Check if a line appears to be a sudo password prompt."""
    stripped = line.strip()
    for pattern in SUDO_PASSWORD_PATTERNS:
        if re.match(pattern, stripped, re.IGNORECASE):
            return True
    return False

def execute_command_with_sudo_retry(
    host: "Host",
    command_arguments: "ConnectorArguments",
    execute_command: Callable[..., tuple[int, CommandOutput]],
) -> tuple[int, CommandOutput]:
    return_code, output = execute_command()

    # If we failed look for a sudo password prompt line and re-submit using the sudo password. Look
    # at all lines here in case anything else gets printed, eg in:
    # https://github.com/pyinfra-dev/pyinfra/issues/1292
    if return_code != 0 and output and output.combined_lines:
        for line in reversed(output.combined_lines):
            if _is_sudo_password_prompt(line.line):
                # If we need a password, ask the user for it and attach to the host
                # internal connector data for use when executing future commands.
                sudo_password = getpass("{0}sudo password: ".format(host.print_prefix))
                host.connector_data["prompted_sudo_password"] = sudo_password
                return_code, output = execute_command()
                break

    return return_code, output
```

### Option 2: Configurable Sudo Prompt Pattern

Add a configuration option to allow users to specify custom sudo prompt patterns:

```python
# In config or connector data
SUDO_PROMPT_PATTERN = r"^\[sudo\] password for .+:|^sudo: a password is required$"
```

### Option 3: Use sudo's -p Option (Partial Fix)

When pyinfra invokes sudo directly, use the `-p` option to set a predictable prompt:

```python
# In make_unix_command, when building sudo command
command_bits.extend(["sudo", "-H", "-p", "PYINFRA_SUDO_PROMPT:"])
```

However, this only helps for commands pyinfra directly wraps with sudo, not for commands that internally invoke sudo.

## Files That Need Modification

### Primary Changes

1. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/util.py`**
   - Add regex import at top of file
   - Add `SUDO_PASSWORD_PATTERNS` constant
   - Add `_is_sudo_password_prompt()` helper function
   - Modify `execute_command_with_sudo_retry()` to use pattern matching

### Test Updates

2. **`/Users/nick/Dev/Webroot/pyinfra/src/tests/test_connectors/test_ssh.py`**
   - Add new test cases for different sudo prompt formats
   - Existing tests at lines 593-654, 656-717, 722-757 test sudo password prompts and should continue to pass
   - Add tests for:
     - `[sudo] password for user:` format
     - Case-insensitive matching
     - Prompts followed by other output (like WARNING lines)

### Example New Test Case

```python
@mock.patch("pyinfra.connectors.util.getpass")
@mock.patch("pyinfra.connectors.ssh.SSHClient")
def test_run_shell_command_sudo_password_interactive_prompt(
    self,
    fake_ssh_client,
    fake_getpass,
):
    """Test that pyinfra recognizes interactive sudo prompts like [sudo] password for user:"""
    fake_ssh = mock.MagicMock()
    first_fake_stdout = mock.MagicMock()
    second_fake_stdout = mock.MagicMock()
    third_fake_stdout = mock.MagicMock()

    # Simulate the interactive sudo prompt format
    first_fake_stdout.__iter__.return_value = ["[sudo] password for testuser:\r"]
    second_fake_stdout.__iter__.return_value = ["/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX"]

    fake_ssh.exec_command.side_effect = [
        (mock.MagicMock(), first_fake_stdout, mock.MagicMock()),
        (mock.MagicMock(), second_fake_stdout, mock.MagicMock()),
        (mock.MagicMock(), third_fake_stdout, mock.MagicMock()),
    ]

    fake_ssh_client.return_value = fake_ssh
    fake_getpass.return_value = "password"

    inventory = make_inventory(hosts=("somehost",))
    State(inventory, Config())
    host = inventory.get_host("somehost")
    host.connect()

    command = "makepkg -s"
    first_fake_stdout.channel.recv_exit_status.return_value = 1
    second_fake_stdout.channel.recv_exit_status.return_value = 0
    third_fake_stdout.channel.recv_exit_status.return_value = 0

    out = host.run_shell_command(command, _sudo=True)
    assert out[0] is True
```

## Impact Assessment

### Risk Level: Low

The proposed fix is backward compatible:
- The existing exact match pattern is included in the new regex patterns
- Existing tests should continue to pass
- No changes to the public API

### Potential Side Effects

1. **False positives**: The broader pattern matching could potentially match lines that are not sudo prompts. However, this is mitigated by:
   - Only triggering on command failure (return_code != 0)
   - Using specific patterns rather than overly broad matching
   - The user still needs to enter the correct password

2. **Locale/Language considerations**: Sudo prompts may be localized in non-English systems. The current fix addresses the most common English formats but may need expansion for internationalization.

## Additional Considerations

### Alternative Approach: Use PTY Mode

One potential workaround for users is to use `_get_pty=True` when running commands that internally invoke sudo. This allows for more interactive handling but may have other side effects.

### Environment Detection

Another approach would be to detect when a command hangs waiting for input and provide a timeout-based prompt, but this is more complex to implement correctly.

## References

- GitHub Issue: https://github.com/pyinfra-dev/pyinfra/issues/1292
- Source file: `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/util.py`
- Related tests: `/Users/nick/Dev/Webroot/pyinfra/src/tests/test_connectors/test_ssh.py`
- sudo man page: https://www.sudo.ws/docs/man/sudo.man/
