# GitHub Issue #736: files.sync ignores mode on destination directory

## Issue Summary

**Title:** files.sync ignores mode on destination directory
**URL:** https://github.com/pyinfra-dev/pyinfra/issues/736
**Status:** Open
**Created:** 2022-01-08
**Author:** si14

### Original Report

The `files.sync` operation always applies the host's file permission on the destination directory regardless of the `mode` parameter in the command. This can lead to security issues where directories become world-readable/writable when the user intended to restrict permissions.

Example from the issue:

```python
files.sync(
    name="Sync static",
    sudo=True,
    src=str(HOST_REPO_ROOT / "staticweb"),
    dest=str(TARGET_ROOT / "staticweb"),
    user="root",
    group="www-data",
    mode=660,
)
```

This might lead to `staticweb` being listable for everyone on the target host, which is a security concern.

### Observed Behavior (from comments)

User filips123 reproduced the issue on Windows (running pyinfra from Windows targeting a Debian server):

```sh
$ ls -al /opt/test/
total 12
drwxrwxrwx 3 root root 4096 Aug  9 19:25 ./
drwxr-xr-x 5 root root 4096 Aug  9 19:25 ../
-rw-r--r-- 1 root root    0 Aug  9 19:25 first.txt
drwxrwxrwx 2 root root 4096 Aug  9 19:25 nested/
-rw-r--r-- 1 root root    0 Aug  9 19:25 second.txt
```

Files get the correct permissions (644), but directories get 777 instead of the expected mode.

### Platform-Specific Notes

- The issue appears to be **Windows-specific** (when running pyinfra from Windows)
- Users running from Linux/macOS have not been able to reproduce the issue
- Recent comments suggest the issue may work correctly on non-Windows platforms

---

## Root Cause Analysis

### Current Implementation

The `sync` operation is located in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py` (lines 551-696).

The operation now includes a `dir_mode` parameter (line 558):

```python
def sync(
    src: str,
    dest: str,
    user: str | None = None,
    group: str | None = None,
    mode: str | None = None,
    dir_mode: str | None = None,  # <-- This parameter exists now
    delete=False,
    exclude: str | list[str] | tuple[str] | None = None,
    exclude_dir: str | list[str] | tuple[str] | None = None,
    add_deploy_dir=True,
):
```

When syncing directories, the code uses `dir_mode or get_path_permissions_mode(...)`:

```python
# Line 657-662: Ensure the destination directory
yield from directory._inner(
    path=dest_to_ensure,
    user=user,
    group=group,
    mode=dir_mode or get_path_permissions_mode(src),  # Falls back to local permissions
)

# Line 665-671: Ensure any remote dirnames
for dir_path_curr, dir_mode_curr in ensure_dirnames:
    yield from directory._inner(
        path=unix_path_join(dest, dir_path_curr),
        user=user,
        group=group,
        mode=dir_mode or dir_mode_curr,  # Falls back to local permissions
    )
```

### The `get_path_permissions_mode` Function

Located in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/util.py` (lines 452-458):

```python
def get_path_permissions_mode(pathname: str):
    """
    Get the permissions (bits) of a path as an integer.
    """

    mode_octal = oct(stat(pathname).st_mode)
    return int(mode_octal[-3:])
```

### The Windows Problem

On Windows, the file permission model is fundamentally different from Unix/POSIX:

1. **Windows does not have Unix-style file permissions** (rwx for user/group/other)
2. `stat(pathname).st_mode` on Windows returns different values than on Unix
3. On Windows, directories typically report permissions like `777` (full access) because Windows doesn't track Unix permissions
4. The `get_path_permissions_mode` function extracts the last 3 octal digits, which on Windows may not reflect meaningful Unix permissions

**This is why users on Windows see directories with `777` permissions:**
- The local Windows directory has no meaningful Unix permissions
- `get_path_permissions_mode()` returns `777` (or similar high-permission value)
- This value is applied to the remote directory

### Why Files Work Correctly

For files, the `mode` parameter is used directly when specified:

```python
# Line 674-683
for local_filename, remote_filename in put_files:
    yield from put._inner(
        src=local_filename,
        dest=remote_filename,
        user=user,
        group=group,
        mode=mode or get_path_permissions_mode(local_filename),  # Uses 'mode' param if provided
        ...
    )
```

If a user specifies `mode=660`, files get that mode. But for directories, the original issue was that only local permissions were used (no `dir_mode` parameter existed).

---

## Current Status

Looking at the current codebase, the `dir_mode` parameter **has been added** to the `sync` operation. This partially addresses the issue by allowing users to explicitly set directory permissions.

However, there are still issues:

1. **The `mode` parameter is documented only for files**: The docstring says "mode: permissions of the files", but users might expect it to apply to directories too
2. **Windows fallback is still problematic**: When `dir_mode` is not specified, the code falls back to `get_path_permissions_mode()`, which returns incorrect values on Windows
3. **Documentation may be unclear**: Users may not realize they need to use both `mode` and `dir_mode`

---

## Proposed Fix

### Option 1: Use `dir_mode` as fallback, then `mode` (Recommended)

Modify the directory mode logic to fall back to the `mode` parameter if `dir_mode` is not specified, before falling back to local permissions. This matches user expectations.

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`

**Change 1 (line 657-662):**

```python
# Current:
yield from directory._inner(
    path=dest_to_ensure,
    user=user,
    group=group,
    mode=dir_mode or get_path_permissions_mode(src),
)

# Proposed:
yield from directory._inner(
    path=dest_to_ensure,
    user=user,
    group=group,
    mode=dir_mode or mode or get_path_permissions_mode(src),
)
```

**Change 2 (line 665-671):**

```python
# Current:
for dir_path_curr, dir_mode_curr in ensure_dirnames:
    yield from directory._inner(
        path=unix_path_join(dest, dir_path_curr),
        user=user,
        group=group,
        mode=dir_mode or dir_mode_curr,
    )

# Proposed:
for dir_path_curr, dir_mode_curr in ensure_dirnames:
    yield from directory._inner(
        path=unix_path_join(dest, dir_path_curr),
        user=user,
        group=group,
        mode=dir_mode or mode or dir_mode_curr,
    )
```

**Update docstring to clarify behavior:**

```python
+ mode: permissions of the files (also used for directories if dir_mode not specified)
+ dir_mode: permissions of the directories (defaults to mode, then local directory permissions)
```

### Option 2: Windows-aware permissions function

Add a Windows check to `get_path_permissions_mode` to return a sensible default on Windows:

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/util.py`

```python
import sys

def get_path_permissions_mode(pathname: str, default_on_windows: int = 755):
    """
    Get the permissions (bits) of a path as an integer.

    On Windows, file permissions are not meaningful in Unix terms,
    so a default value is used for directories.
    """
    if sys.platform == 'win32':
        from os.path import isdir
        if isdir(pathname):
            return default_on_windows

    mode_octal = oct(stat(pathname).st_mode)
    return int(mode_octal[-3:])
```

### Option 3: Combination approach (Most Robust)

Combine both approaches: Allow `mode` to be used as fallback for directories, AND provide better Windows defaults.

---

## Related Files

1. **Main implementation:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`
   - `sync` function (lines 551-696)

2. **Permission utility:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/util.py`
   - `get_path_permissions_mode` function (lines 452-458)

3. **Tests:** `/Users/nick/Dev/Webroot/pyinfra/src/tests/operations/files.sync/`
   - `sync_no_destination_exist.json`
   - Other sync-related test files

---

## Recommended Action

1. **Implement Option 1** (use `mode` as fallback for `dir_mode`) - This is the minimal change that matches user expectations
2. **Optionally implement Option 2** for better Windows compatibility
3. **Add tests** for the scenario where `mode` is specified but `dir_mode` is not
4. **Update documentation** to clarify the behavior of `mode` vs `dir_mode`

---

## Test Cases to Add

1. Test `files.sync` with `mode` specified but `dir_mode` not specified - directories should use `mode`
2. Test `files.sync` with both `mode` and `dir_mode` specified - each should be applied correctly
3. Test `files.sync` from Windows (mock) to verify reasonable defaults are applied
