How to Handle File Permissions in Rust

Rust handles concurrency safety via compile-time ownership rules and Mutex locks instead of runtime file permissions.

When the OS says no

You write a configuration file, deploy your application, and it crashes on startup because it cannot read the file. Or you accidentally ship a helper script with world-writable permissions and wonder why your server got compromised. File permissions feel like a legacy Unix problem, but they show up in every Rust program that touches the disk. The operating system enforces the rules. Rust just provides the bridge to ask the OS what it allows and what it blocks.

The universal keycard

Rust's standard library treats file permissions like a building's access control system. The cross-platform layer gives you a single universal keycard that tracks exactly one thing: can you write to this file, or is it locked for reading only. That is the entire API for std::fs::Permissions on the base layer. If you want to specify which user gets the key, how many copies exist, or whether the file can execute as a program, you have to step outside the universal system and use the local security office's forms. On Unix, that means octal mode bits. On Windows, that means ACLs and the read-only attribute. Rust keeps the shared layer intentionally thin so your code does not break when you move from a Linux server to a Windows desktop.

The standard library gives you the basics. Everything else requires stepping into platform-specific territory.

Reading what the system allows

You start by asking the operating system for the file's metadata. The std::fs::metadata function queries the filesystem and returns a Metadata struct. That struct contains timestamps, file size, and a permissions() method. The method returns a type that implements the Permissions trait. The trait only exposes two methods: readonly() and set_readonly(). This design forces you to handle the common case without pulling in platform-specific crates.

use std::fs;
use std::path::Path;

/// Check whether a file accepts writes or is locked for reading.
fn check_write_access(path: &Path) -> std::io::Result<()> {
    // Query the OS for file metadata. Returns an error if the path does not exist.
    let metadata = fs::metadata(path)?;
    
    // Extract the permission set. This is a trait object, not a concrete struct.
    let permissions = metadata.permissions();

    // The cross-platform layer only tracks the read-only flag.
    if permissions.readonly() {
        println!("Locked for reading");
    } else {
        println!("Accepts writes");
    }

    Ok(())
}

When you call fs::metadata, the program makes a system call to the kernel. The kernel looks up the inode or file record, checks your process's credentials, and hands back a snapshot of the file's state. Rust wraps that snapshot in a Metadata value. Calling permissions() does not make another system call. It just returns a view into the permission data that was already fetched. The readonly() method translates the OS-specific flags into a simple boolean. On Unix, it checks whether the write bits are cleared for the current user. On Windows, it checks the read-only file attribute. The compiler does not enforce these rules. The OS does. Rust just hands you the remote.

Trust the OS to enforce the rules. Rust just hands you the remote.

Setting Unix mode bits

The universal keycard works for simple cases. Real applications often need precise control. You want a script to be executable. You want a config file to be readable by everyone but writable only by the owner. You need 0o755 or 0o644. That requires platform-specific extensions. Rust gates these behind std::os::unix::fs::PermissionsExt and std::os::windows::fs::PermissionsExt. You import the trait, call set_mode, and pass an octal literal. The 0o prefix is mandatory in modern Rust. The old 0755 syntax was removed to avoid confusion with decimal numbers.

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

/// Apply standard Unix permissions to a file.
fn set_standard_permissions(path: &Path, mode: u32) -> std::io::Result<()> {
    // Fetch current metadata to get the existing Permissions object.
    let mut perms = fs::metadata(path)?.permissions();
    
    // Apply the octal mode. 0o755 means rwx for owner, r-x for group and others.
    perms.set_mode(mode);
    
    // Push the updated permissions back to the filesystem.
    fs::set_permissions(path, perms)?;
    
    Ok(())
}

The set_mode method does not replace the existing permissions atomically. It modifies the Permissions object in memory, and fs::set_permissions sends the update to the kernel. The kernel applies the new bits, but it also respects the process's umask. If your shell has a restrictive umask, the kernel will mask out bits you tried to set. This is a common surprise. The umask runs before the permission bits hit the disk. If you need exact control regardless of the environment, you have to adjust the umask temporarily or use a lower-level syscall. For most applications, relying on the default umask is fine. The community convention is to keep platform-specific code behind #[cfg(unix)] or #[cfg(windows)] attributes. Your cross-platform promise depends on it.

Keep platform-specific code behind cfg gates. Your cross-platform promise depends on it.

Where things go wrong

File permission code fails in predictable ways. The compiler will not stop you from calling set_mode(0o777). It will not warn you that Windows does not understand octal modes. The failures happen at runtime when the kernel rejects your request.

The most common error is std::io::ErrorKind::PermissionDenied. This happens when your process lacks the credentials to modify the file. If you run a program as a standard user and try to change permissions on a root-owned file, the kernel returns EPERM or EACCES. Rust translates that into a PermissionDenied error. If you ignore the Result and call unwrap(), your program panics. If you use ?, the error bubbles up and your function returns early. Both are valid strategies, but swallowing the error hides the failure until it crashes in production.

Another trap is assuming fs::set_permissions works on directories the same way it works on files. On Unix, directory permissions control whether you can list contents, create new files, or delete existing ones. The x bit on a directory means you can traverse it, not execute it. Setting 0o755 on a directory gives the owner full control and allows others to read and traverse it. Setting 0o644 on a directory blocks traversal entirely. Users will complain they cannot access files inside a folder that appears readable. The bit semantics differ between files and directories, and the standard library does not distinguish them. You have to track that distinction yourself.

The compiler will reject you with E0277 (trait bound not satisfied) if you try to call set_mode without importing PermissionsExt. The trait is not in scope by default. You must bring it into scope with a use statement, or the compiler treats perms as a base Permissions object that only knows about readonly(). This is intentional. The language forces you to acknowledge that you are stepping outside the cross-platform safety net.

Never assume a permission change succeeded. Check the Result or let your program fail fast.

Picking the right tool

File permission handling splits into clear paths based on your target platforms and your precision requirements. The standard library gives you the building blocks. You just need to match the block to the job.

Use metadata.permissions().readonly() when you only need to know if a file accepts writes across any operating system. Use std::os::unix::fs::PermissionsExt when you need precise Unix mode bits like 0o644 or 0o755. Use std::os::windows::fs::PermissionsExt when you need to toggle the Windows read-only attribute or work with file attributes like FILE_ATTRIBUTE_HIDDEN. Reach for a crate like uutils or walkdir when you need to recursively apply permissions across a directory tree. Reach for std::fs::set_permissions when you have already constructed a Permissions object and need to push it to the disk. Reach for fs::metadata when you need to inspect a file before deciding whether to modify it.

The standard library does not hide the OS from you. It hands you the exact tools you need and forces you to acknowledge when you are leaving the shared path. Treat the platform-specific imports as a contract. If you import PermissionsExt, you are promising to handle the differences yourself.

Where to go next