# Issue #1440: files.get with sudo fails with "Permission denied"

## Issue Summary

**GitHub Issue:** [#1440](https://github.com/pyinfra-dev/pyinfra/issues/1440)
**Reporter:** @KarolBedkowski
**Labels:** bug, operations
**Status:** Open

### Problem Description

When using `files.get(..., _sudo=True)` to download a file from a remote server, the operation fails with a `PermissionError(13, 'Permission denied')` error. The file is successfully copied to a temporary location with sudo, but the subsequent SFTP download fails because the temporary directory is not accessible to the SSH connection user.

### Steps to Reproduce

```python
files.get(
    name="get",
    src="/etc/nftables.conf",
    dest="nftables.conf",
    force=True,
    _sudo=True,
)
```

### Observed Behavior

The operation flow:
1. pyinfra runs `sudo stat` on the file (succeeds - shows file owned by root:adm with mode `-rwxr-x---`)
2. pyinfra runs `sudo cp /etc/nftables.conf /tmp/user/0/pyinfra-... && chmod +r /tmp/user/0/pyinfra-...` (succeeds)
3. pyinfra attempts SFTP download of `/tmp/user/0/pyinfra-...` (fails with Permission denied)
4. pyinfra runs `sudo rm -f /tmp/user/0/pyinfra-...` (this runs but the error already occurred)

The root cause is that `/tmp/user/0/` is root's private temporary directory (mode 700), so the SSH connection user cannot access files inside it even though the file itself has been made readable via `chmod +r`.

### Secondary Issue

If the remote operation fails after opening the local file for writing, the local file is truncated to zero bytes. The `_get_file` method opens the file with `"wb"` mode before attempting the SFTP transfer.

---

## Root Cause Analysis

### Primary Issue: Temp Directory Selection in Sudo Context

The issue is in how pyinfra determines the temporary directory when sudo is used.

**Code Path:**

1. `files.get()` operation in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py` (lines 773-841):
   ```python
   # Use _temp_dir global argument if provided
   temp_dir = None
   if host.current_op_global_arguments:
       temp_dir = host.current_op_global_arguments.get("_temp_dir")

   # No remote file, so assume exists and download it "blind"
   if not remote_file or force:
       yield FileDownloadCommand(
           src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
       )
   ```

2. `host.get_temp_filename()` in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/host.py` (lines 334-353):
   ```python
   def get_temp_filename(
       self,
       hash_key: Optional[str] = None,
       hash_filename: bool = True,
       *,
       temp_directory: Optional[str] = None,
   ):
       temp_directory = temp_directory or self._get_temp_directory()
       # ...
   ```

3. `host._get_temp_directory()` in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/host.py` (lines 315-329):
   ```python
   @memoize
   def _get_temp_directory(self):
       temp_directory = self.state.config.TEMP_DIR

       if temp_directory is None:
           from pyinfra.facts.server import TmpDir
           temp_directory = self.get_fact(TmpDir)

       if not temp_directory:
           temp_directory = self.state.config.DEFAULT_TEMP_DIR

       return temp_directory
   ```

4. `TmpDir` fact in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/server.py` (lines 53-76):
   ```python
   class TmpDir(FactBase):
       def command(self):
           return """
   if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ] && [ -w "$TMPDIR" ]; then
       echo "$TMPDIR"
   elif [ -n "$TMP" ] && [ -d "$TMP" ] && [ -w "$TMP" ]; then
       echo "$TMP"
   elif [ -n "$TEMP" ] && [ -d "$TEMP" ] && [ -w "$TEMP" ]; then
       echo "$TEMP"
   else
       echo ""
   fi
           """.strip()
   ```

5. `ssh.get_file()` in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/ssh.py` (lines 463-528):
   ```python
   def get_file(
       self,
       remote_filename: str,
       filename_or_io,
       remote_temp_filename=None,
       ...
       **arguments: Unpack["ConnectorArguments"],
   ) -> bool:
       _sudo = arguments.get("_sudo", False)
       _su_user = arguments.get("_su_user", None)

       if _sudo or _su_user:
           # Get temp file location
           temp_file = remote_temp_filename or self.host.get_temp_filename(remote_filename)

           # Copy the file to the tempfile location and add read permissions
           command = StringCommand(
               "cp", remote_filename, temp_file, "&&", "chmod", "+r", temp_file
           )
           # ... execute with sudo ...

           try:
               self._get_file(temp_file, filename_or_io)  # SFTP without sudo
           finally:
               # ... cleanup ...
   ```

**The Problem:**

When sudo is used, the `TmpDir` fact is typically executed with sudo (due to global arguments), which means it runs in root's context. On systems with `pam_systemd` or similar PAM modules, root's `$TMPDIR` environment variable points to `/tmp/user/0/` (or similar user-specific temp directory), which is mode 700.

The temp file is created with sudo in `/tmp/user/0/`, made readable, but the directory itself is not accessible to the SSH connection user, so the SFTP download fails.

### Secondary Issue: Local File Truncation

In `ssh._get_file()` (lines 458-461):
```python
def _get_file(self, remote_filename: str, filename_or_io: str | IO):
    with get_file_io(filename_or_io, "wb") as file_io:  # Opens file in write mode
        sftp = self.get_file_transfer_connection()
        sftp.getfo(remote_filename, file_io)  # If this fails, file is already truncated
```

The file is opened for writing before the SFTP transfer begins. If the transfer fails, the local file is left truncated.

---

## Proposed Fix

### Fix 1: Use a Globally Accessible Temp Directory for Sudo Operations

The most reliable fix is to ensure the temp file is created in a directory accessible to the SSH user. There are several approaches:

#### Option A: Always use `/tmp` for sudo file operations (Simplest)

In `ssh.py`, when sudo is enabled for file operations, explicitly use `/tmp` instead of relying on the memoized temp directory:

```python
# In ssh.py get_file() method, around line 479
if _sudo or _su_user:
    # When using sudo, always use /tmp to ensure SSH user can access the file
    # User's TMPDIR (e.g., /tmp/user/0/) may not be accessible to SSH user
    if remote_temp_filename is None:
        remote_temp_filename = self.host.get_temp_filename(
            remote_filename,
            temp_directory="/tmp"
        )
    temp_file = remote_temp_filename
```

Similarly for `put_file()` around line 575.

#### Option B: Override temp directory in files.get() operation

Modify `files.get()` to use a safe temp directory when sudo is enabled:

```python
# In operations/files.py get() function, around line 814
# Determine temp directory - use /tmp for sudo to ensure SSH user can access
temp_dir = None
if host.current_op_global_arguments:
    temp_dir = host.current_op_global_arguments.get("_temp_dir")
    # If using sudo and no explicit temp_dir, use /tmp to avoid permission issues
    if temp_dir is None and host.current_op_global_arguments.get("_sudo"):
        temp_dir = "/tmp"
```

#### Option C: Detect and warn about inaccessible temp directories (More complex)

Before using the temp directory, check if it's accessible to the SSH user. This is more complex and may require an additional fact check.

### Fix 2: Prevent Local File Truncation

Modify `ssh._get_file()` to use a temporary local file:

```python
def _get_file(self, remote_filename: str, filename_or_io: str | IO):
    import tempfile
    import shutil

    # If filename_or_io is a string (file path), use a temp file first
    if isinstance(filename_or_io, str):
        with tempfile.NamedTemporaryFile(delete=False) as temp_local:
            temp_local_path = temp_local.name

        try:
            with open(temp_local_path, "wb") as file_io:
                sftp = self.get_file_transfer_connection()
                sftp.getfo(remote_filename, file_io)
            # Only move to final destination if successful
            shutil.move(temp_local_path, filename_or_io)
        except Exception:
            # Clean up temp file on failure
            if os.path.exists(temp_local_path):
                os.remove(temp_local_path)
            raise
    else:
        # File-like object - original behavior (can't avoid truncation)
        with get_file_io(filename_or_io, "wb") as file_io:
            sftp = self.get_file_transfer_connection()
            sftp.getfo(remote_filename, file_io)
```

---

## Files Requiring Modification

1. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/ssh.py`**
   - `get_file()` method (lines 463-528): Use `/tmp` as default temp directory when sudo is enabled
   - `put_file()` method (lines 550-632): Same fix for consistency
   - `_get_file()` method (lines 458-461): Prevent local file truncation

2. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`** (Alternative approach)
   - `get()` function (lines 773-841): Override temp directory when sudo is used

3. **Tests to add:**
   - Test `files.get()` with sudo on a system with user-specific TMPDIR
   - Test that local file is not truncated on download failure

---

## Recommended Solution

The simplest and most robust solution is **Option A**: In the SSH connector's `get_file()` and `put_file()` methods, when `_sudo` or `_su_user` is active, explicitly use `/tmp` as the temp directory unless a specific `_temp_dir` is provided.

This approach:
- Is localized to the SSH connector where the file transfer actually happens
- Doesn't require changes to the operation layer
- Is backward compatible (users can still override with `_temp_dir`)
- Addresses the root cause directly

### Specific Code Change

In `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/ssh.py`:

```python
# In get_file() method, replace lines 479-484 with:
if _sudo or _su_user:
    # When using sudo/su, use /tmp by default to ensure SSH user can access
    # the file. User-specific TMPDIR (e.g., /tmp/user/0/) may not be accessible.
    if remote_temp_filename is None:
        remote_temp_filename = self.host.get_temp_filename(
            remote_filename,
            temp_directory="/tmp"
        )
    temp_file = remote_temp_filename

# In put_file() method, replace lines 575-577 with:
if _sudo or _doas or _su_user:
    # When using sudo/doas/su, use /tmp by default to ensure SSH user can access
    # the file. User-specific TMPDIR (e.g., /tmp/user/0/) may not be accessible.
    if remote_temp_filename is None:
        remote_temp_filename = self.host.get_temp_filename(
            remote_filename,
            temp_directory="/tmp"
        )
    temp_file = remote_temp_filename
```

---

## References

- Issue discussion: https://github.com/pyinfra-dev/pyinfra/issues/1440
- User's workaround (confirmed working): Setting `temp_directory="/tmp"` in `get_temp_filename()` call
- Related code paths in this investigation
