# GitHub Issue #1426 Investigation Report

## Issue Summary

**Title:** file.put with _sudo=True and _chdir is not None can fail if the sudo is required for the chdir to succeed

**Reporter:** morrison12
**Status:** Open
**Created:** 2025-08-12

### Problem Description

When using `files.put` with `_sudo=True` and `_chdir` pointing to a directory that requires sudo permissions to access, the operation fails. The issue is that the temporary file cleanup command runs with `_chdir` but **without** `_sudo`, causing a "Permission denied" error on the `cd` command.

### Reproduction Scenario

```python
import io
from pyinfra.operations import files

USER_DIR = "/home/grace"  # A directory the SSH user cannot access without sudo

content = io.StringIO("FOO")
file_name = "foo"

files.put(
    content,
    file_name,
    name='create a file in the new directory',
    _chdir=USER_DIR,
    _sudo=True
)
```

### Error Output

```
[domo] >>> sudo -H -n sh -c 'cd /home/grace && cp /tmp/pyinfra-... foo'
# ... (succeeds)
[domo] >>> sh -c 'cd /home/grace && rm -f /tmp/pyinfra-...'
[domo] sh: line 1: cd: /home/grace: Permission denied
Unable to remove temporary file: sh: line 1: cd: /home/grace: Permission denied
```

---

## Root Cause Analysis

The bug is located in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/ssh.py` in the `put_file` method (lines 550-632).

### The Problem Flow

1. **Line 565:** The `put_file` method saves the original arguments:
   ```python
   original_arguments = arguments.copy()
   ```

2. **Lines 567-571:** The method pops sudo/doas/su-related arguments from `arguments`:
   ```python
   _sudo = arguments.pop("_sudo", False)
   _sudo_user = arguments.pop("_sudo_user", False)
   _doas = arguments.pop("_doas", False)
   _doas_user = arguments.pop("_doas_user", False)
   _su_user = arguments.pop("_su_user", None)
   ```

3. **Lines 597-602:** The copy command uses `original_arguments` (which includes `_sudo` and `_chdir`):
   ```python
   status, output = self.run_shell_command(
       command,
       print_output=print_output,
       print_input=print_input,
       **original_arguments,  # <-- Contains _sudo and _chdir
   )
   ```

4. **Lines 609-616:** The remove command uses the modified `arguments` (which has `_sudo` removed but still has `_chdir`):
   ```python
   command = StringCommand("rm", "-f", temp_file)

   status, output = self.run_shell_command(
       command,
       print_output=print_output,
       print_input=print_input,
       **arguments,  # <-- _sudo was removed, but _chdir remains!
   )
   ```

### Why This Causes the Bug

The `_chdir` argument is passed to the remove command, but `_sudo` is not. When `make_unix_command_for_host` builds the command, it creates:

```sh
sh -c 'cd /home/grace && rm -f /tmp/pyinfra-...'
```

Since the user doesn't have permission to `cd /home/grace` without sudo, the command fails.

### Additional Context

The reporter identified two key code locations:
- Line 565: Where `_sudo` is removed from `arguments`
- Lines 607-618: Where the temp file removal happens using the modified `arguments`

---

## Proposed Fix

### Option 1: Remove _chdir from the temp file removal command (Recommended)

The temporary file is stored in `/tmp` (or the configured temp directory), which is accessible without sudo. The `_chdir` argument is unnecessary for removing this file since:
1. The temp file path is absolute
2. The temp directory is accessible to the SSH user

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

**Current code (lines 608-616):**
```python
# Delete the temporary file now that we've successfully copied it
command = StringCommand("rm", "-f", temp_file)

status, output = self.run_shell_command(
    command,
    print_output=print_output,
    print_input=print_input,
    **arguments,
)
```

**Proposed change:**
```python
# Delete the temporary file now that we've successfully copied it
# Note: We don't pass _chdir here because the temp file is in an absolute
# path that should be accessible without changing directories.
command = StringCommand("rm", "-f", temp_file)

# Create a copy without _chdir for the temp file removal
temp_cleanup_arguments = {k: v for k, v in arguments.items() if k != "_chdir"}

status, output = self.run_shell_command(
    command,
    print_output=print_output,
    print_input=print_input,
    **temp_cleanup_arguments,
)
```

### Option 2: Pass _sudo along with _chdir for consistency

If there's a reason to keep `_chdir` in the removal command, then we should also keep `_sudo` to ensure the cd can succeed.

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

**Current code (lines 608-616):**
```python
# Delete the temporary file now that we've successfully copied it
command = StringCommand("rm", "-f", temp_file)

status, output = self.run_shell_command(
    command,
    print_output=print_output,
    print_input=print_input,
    **arguments,
)
```

**Proposed change:**
```python
# Delete the temporary file now that we've successfully copied it
# Reconstruct arguments with the sudo settings to ensure _chdir can succeed
command = StringCommand("rm", "-f", temp_file)

temp_cleanup_arguments = arguments.copy()
if _sudo:
    temp_cleanup_arguments["_sudo"] = _sudo
if _sudo_user:
    temp_cleanup_arguments["_sudo_user"] = _sudo_user
if _doas:
    temp_cleanup_arguments["_doas"] = _doas
if _doas_user:
    temp_cleanup_arguments["_doas_user"] = _doas_user
if _su_user:
    temp_cleanup_arguments["_su_user"] = _su_user

status, output = self.run_shell_command(
    command,
    print_output=print_output,
    print_input=print_input,
    **temp_cleanup_arguments,
)
```

### Recommendation

**Option 1 is preferred** because:
1. It's simpler
2. The temp file removal doesn't need `_chdir` - the path is absolute
3. Not requiring sudo for temp file cleanup reduces attack surface
4. It matches the expected behavior - the temp file is uploaded via SFTP as the SSH user, so it should be deletable as the SSH user

---

## Related Files That May Need Review

### 1. SSH Connector - setfacl command (lines 583-592)

The same issue may affect the `setfacl` command that runs earlier in the `put_file` method. This command also uses `**arguments` (without `_sudo`), which could fail if the temp file location requires elevated permissions. However, since the temp file is uploaded via SFTP to the temp directory, this is less likely to be an issue.

```python
# Line 583-588
status, output = self.run_shell_command(
    StringCommand("setfacl", "-m", f"u:{other_user}:r", temp_file),
    print_output=print_output,
    print_input=print_input,
    **arguments,
)
```

This command also should not need `_chdir` since `temp_file` is an absolute path.

### 2. get_file method (lines 463-528)

The `get_file` method has a similar pattern where it removes a temporary file. Review this to ensure it doesn't have the same issue:

```python
# Line 508-513
remove_status, output = self.run_shell_command(
    StringCommand("rm", "-f", temp_file),
    print_output=print_output,
    print_input=print_input,
    **arguments,
)
```

In `get_file`, the `arguments` dict is passed through unchanged, so it includes `_sudo`. This is correct and consistent with the copy operation. The issue is specific to `put_file` where `_sudo` is explicitly removed.

### 3. Other connectors

- **local.py:** Uses `**arguments` directly in `cp` command, keeping all arguments. No issue.
- **docker.py:** Ignores sudo/kwargs for put_file. No issue (docker handles permissions differently).
- **chroot.py:** Ignores sudo/kwargs for put_file. No issue (chroot handles permissions differently).
- **dockerssh.py:** Inherits from SSHConnector, would have the same issue.

---

## Test Plan

1. Create a test case where:
   - Target directory requires sudo to access
   - Use `files.put` with `_sudo=True` and `_chdir=<protected_dir>`
   - Verify the operation completes successfully

2. Verify that temp files are properly cleaned up after successful upload

3. Test edge cases:
   - `_sudo=True` without `_chdir`
   - `_chdir` without `_sudo` (to an accessible directory)
   - `_su_user` with `_chdir`
   - `_doas` with `_chdir`

---

## Summary

The bug is a simple oversight where `_sudo` is removed from the arguments dict before the temp file cleanup command, but `_chdir` is not. The fix is straightforward: either remove `_chdir` from the cleanup command (recommended) or ensure `_sudo` is also included. Option 1 is preferred as the simpler and more correct solution.
