Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

9.4 · Symbolic Links, Hard Links, and File Copying

Domain 9 — Filesystem Operations Duration: ~15 minutes Library components: std::fs::copy, std::fs::rename, std::fs::hard_link, std::fs::read_link, std::os::unix::fs, std::os::windows::fs

Introduction

It seems easy to move data in a filesystem, but the details are important:

  • rename is atomic, but only in one filesystem.
  • copy duplicates permission bits that you possibly do not want.
  • A hard link is the file, but a symlink only names a path to a file.

This tutorial explains the model behind these operations:

  • fs::copy and fs::rename, and the pattern behind each safe config update: write to a temporary file, then rename.
  • Cross-device moves: why rename fails with ErrorKind::CrossesDevices, and which alternative to use.
  • fs::hard_link: two directory entries, one inode.
  • Platform-specific symlink creation, fs::read_link, and dangling links.

Figure: Hard links vs symbolic links

fs::copy

fs::copy(from, to) reads from and writes its contents to to. It returns the number of bytes that it copied:

// original: PathBuf of a file that contains 4096 bytes.
// backup: PathBuf of the destination.
// The code is in a function that returns io::Result, so `?` is permitted.
let copied = fs::copy(&original, &backup)?;    // u64: the number of bytes copied
assert_eq!(copied, 4096);
assert!(original.exists() && backup.exists()); // copy does not change or delete the source

Remember these three behaviors:

  • It overwrites an existing destination and does not ask. There is no "no-clobber" flag. If that is important, first check with Path::try_exists, or open the destination yourself with create_new.
  • It copies the permission bits together with the contents (it is cp -p, not cp). A read-only source gives a read-only copy. Remember this before you try to change the copy. On Windows, fs::remove_file first uses DeleteFile, which refuses a read-only file. Then it tries a second method that ignores the read-only attribute.
  • It follows symlinks: a copy of a symlink copies the contents of the target.

Internally, fs::copy uses fast OS mechanisms where they are available: copy_file_range on Linux, and fclonefileat or fcopyfile on macOS. Thus it is usually faster than a manual read/write loop through user space.

fs::rename and Atomic Replacement

fs::rename(from, to) moves a file or a full directory tree in one step. If to is an existing file, rename atomically replaces it. Readers see the old file or the new file, never a partially written intermediate state. This guarantee is the base of the standard pattern for a safe update:

// live: PathBuf of the config file in use. It contains {"version": 1}.
// staged: PathBuf of a temporary name in the same directory.
// Step 1: write the complete new version to the temporary name.
fs::write(&staged, "{\"version\": 2}")?;
// Step 2: rename the temporary file to the live name. This step replaces the live file atomically.
fs::rename(&staged, &live)?;
// `staged` does not exist now, and `live` contains {"version": 2}.

cargo, rustup, and each editor with a "safe save" function use this pattern. A crash at any point leaves the intact old file or the intact new file, never half of each. Compare this with a direct fs::write(&live, …): a crash during the write leaves a truncated, half-written file.

The atomicity guarantee comes from the semantics of POSIX rename(2) and Windows MoveFileExW. It applies only in one filesystem, and the next slide explains how to handle that limit.

The platforms differ when the destination is an existing directory:

  • On Unix, rename of a directory can also replace an empty directory.
  • On Windows 10 version 1607 and later, the behavior is the same if the filesystem supports FileRenameInfoEx.
  • On other Windows systems, the destination must not be a directory.

09_12_copy_rename.rs prints the output below. The next slide explains the move_file line:

fs::copy: 4096 bytes; source and backup both exist
fs::copy onto existing file: silently replaced
rename over live config: atomic replace, version 2 visible
rename on a directory: moved with contents
move_file within one filesystem used: rename

All assertions passed.

Cross-Device Moves: rename Fails, copy + delete Is the Alternative

A rename never moves bytes. It changes the directory entries that point at an inode, and an inode number has a meaning only in its own filesystem. If you move a file between mount points, the OS refuses with EXDEV. Two examples are a move from a tmpfs /tmp to your home partition, and a move from a container volume to a bind mount. Rust reports EXDEV as ErrorKind::CrossesDevices (stable since Rust 1.85).

Figure: The move_file fallback strategy

// Moves a file and returns the name of the strategy that it used.
fn move_file(from: &Path, to: &Path) -> io::Result<&'static str> {
    match fs::rename(from, to) {
        Ok(()) => Ok("rename"),
        // CrossesDevices is EXDEV: `from` and `to` are on different filesystems.
        Err(e) if e.kind() == ErrorKind::CrossesDevices => {
            fs::copy(from, to)?;
            fs::remove_file(from)?; // not atomic: two steps that other processes can see
            Ok("copy+delete")
        }
        Err(e) => Err(e), // each other error goes to the caller unchanged
    }
}

mv does the same internally. In 09_12_copy_rename.rs, the two paths are in one filesystem, so move_file returns "rename". The alternative path (copy, then delete) loses these guarantees:

  • Between the copy and the remove_file, the two files exist.
  • A crash at that time leaves the two files.
  • The copy is not atomic: a different process can see it when it is only half-written.

If atomicity is important, copy to a temporary name on the destination filesystem. Then use rename for the last step.

fs::hard_link(original, link) creates a second directory entry for the same inode. The entry is not a pointer and not a copy. The function works on each platform, NTFS included. The two names are fully equal:

// original: PathBuf of a file that contains "id,value\n1,10\n" (14 bytes).
// link: PathBuf of a name that does not exist yet.
fs::hard_link(&original, &link)?;
// A write through one name is visible through the other name.
// The example appends "2,20\n" through `link`. Then `original` contains 19 bytes.

If you delete one name, you do not delete the data. remove_file only unlinks a name, and the inode stays until its last name (and its last open handle) is gone. For this reason, the deletion of a hard-linked backup releases no space. For the same reason, backup tools that deduplicate data (such as rsync --link-dest) and the on-disk caches of cargo use hard links:

fs::remove_file(&original)?;   // deletes the name `original`, not the data
assert_eq!(fs::read_to_string(&link)?, "id,value\n1,10\n2,20\n"); // `link` still has the data

On Unix, MetadataExt::nlink gives the link count: 1 before the link, 2 after it, and 1 again after the unlink. Hard links have two limits, and the two are by design:

  • A hard link cannot span filesystems, because inode numbers are local to a filesystem.
  • A hard link cannot point at a directory, because that would permit cycles in the directory tree.

09_13_hard_links.rs prints:

before hard_link: nlink = 1          # (nlink lines are Unix-only)
after hard_link:  nlink = 2
appended via link: original sees 19 bytes
original removed: data still intact via the remaining link
hard_link to a directory: PermissionDenied   # (exact kind varies by OS)

All assertions passed.

A symlink stores a path. The OS resolves the path on each access, and it resolves a relative path against the parent directory of the link. Windows distinguishes file symlinks from directory symlinks, and historically it restricted who may create them. For this reason, the creation functions are in platform modules, not in the portable std::fs:

  • Unix: std::os::unix::fs::symlink(target, link) is one function for all targets.
  • Windows: std::os::windows::fs::symlink_file and symlink_dir. They require administrator rights or Developer Mode, so the example prints a skip message on Windows.

The functions that read a symlink are portable. fs::read_link returns the stored target verbatim, with no resolution. canonicalize (tutorial 9.3) follows the chain of links to the real file:

use std::os::unix::fs::symlink;

// target: PathBuf of the existing file <tmp>/app-2026-07-10.log (14 bytes).
// link: PathBuf of <tmp>/latest.log, the symlink that this code creates.
symlink("app-2026-07-10.log", &link)?;   // a relative target (log rotation uses this form)
assert_eq!(fs::read_link(&link)?, PathBuf::from("app-2026-07-10.log")); // the stored text
assert_eq!(link.canonicalize()?, target.canonicalize()?);               // the same real file

The difference between "follow" and "do not follow" from tutorial 9.2 is the important concept here. fs::metadata follows the link and describes the target (is_file, the len of the target). fs::symlink_metadata describes the link itself (is_symlink). A dangling link makes the difference visible:

// dangling: PathBuf of a new symlink. Its target, no-such-file.txt, does not exist.
symlink("no-such-file.txt", &dangling)?;                // succeeds: no check of the target
assert!(!dangling.exists());                            // exists() follows the link: false
// try_exists() also follows the link. It returns Ok(false).
assert!(fs::symlink_metadata(&dangling)?.is_symlink()); // but the link itself is there

As with hard links, fs::remove_file(link) deletes the link, never the target.

On Unix, 09_14_symlinks.rs prints:

read_link(latest.log) = app-2026-07-10.log
metadata: file of 14 bytes; symlink_metadata: is_symlink=true
canonicalize(link) == canonicalize(target): true
dangling link: exists()=false, but symlink_metadata finds it
remove_file(link): target survives
read_dir through a dir symlink: 1 entry

All assertions passed.

Summary

ConceptKey point
fs::copyIt returns the number of bytes copied, copies the permission bits, and overwrites an existing destination
fs::renameAtomic move and replace in one filesystem. It also works on directories
Write-temp-then-renameThe standard pattern for crash-safe file updates
ErrorKind::CrossesDevicesEXDEV: a rename across mount points is impossible by design
copy + delete alternativeThe method of mv, but with two visible, non-atomic steps
fs::hard_linkSecond name for the same inode. Portable, NTFS included
Hard-link deletionremove_file unlinks a name. The data stays until the last name is gone
Hard-link limitsNo cross-filesystem links, no directory links
Symlink creationstd::os::unix::fs::symlink. On Windows: symlink_file/symlink_dir and privileges
fs::read_linkIt returns the stored target verbatim, with no resolution
Dangling symlinksexists() follows the link and returns false. symlink_metadata still finds the link
remove_file on a symlinkIt deletes the link, never the target

Code Examples

FileDescription
09_12_copy_rename.rsfs::copy semantics, atomic rename and replace, the helper for the CrossesDevices case
09_13_hard_links.rsfs::hard_link, writes visible through the two names, nlink, deletion semantics
09_14_symlinks.rsUnix symlinks, read_link, dangling links, directory symlinks (the example prints a skip message on Windows)