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:
renameis atomic, but only in one filesystem.copyduplicates 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::copyandfs::rename, and the pattern behind each safe config update: write to a temporary file, then rename.- Cross-device moves: why
renamefails withErrorKind::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 withcreate_new. - It copies the permission bits together with the contents (it is
cp -p, notcp). A read-only source gives a read-only copy. Remember this before you try to change the copy. On Windows,fs::remove_filefirst usesDeleteFile, 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,
renameof 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
copyand theremove_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.
Hard Links: Two Names, One Inode
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.
Symbolic Links
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_fileandsymlink_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
| Concept | Key point |
|---|---|
fs::copy | It returns the number of bytes copied, copies the permission bits, and overwrites an existing destination |
fs::rename | Atomic move and replace in one filesystem. It also works on directories |
| Write-temp-then-rename | The standard pattern for crash-safe file updates |
ErrorKind::CrossesDevices | EXDEV: a rename across mount points is impossible by design |
| copy + delete alternative | The method of mv, but with two visible, non-atomic steps |
fs::hard_link | Second name for the same inode. Portable, NTFS included |
| Hard-link deletion | remove_file unlinks a name. The data stays until the last name is gone |
| Hard-link limits | No cross-filesystem links, no directory links |
| Symlink creation | std::os::unix::fs::symlink. On Windows: symlink_file/symlink_dir and privileges |
fs::read_link | It returns the stored target verbatim, with no resolution |
| Dangling symlinks | exists() follows the link and returns false. symlink_metadata still finds the link |
remove_file on a symlink | It deletes the link, never the target |
Code Examples
| File | Description |
|---|---|
09_12_copy_rename.rs | fs::copy semantics, atomic rename and replace, the helper for the CrossesDevices case |
09_13_hard_links.rs | fs::hard_link, writes visible through the two names, nlink, deletion semantics |
09_14_symlinks.rs | Unix symlinks, read_link, dangling links, directory symlinks (the example prints a skip message on Windows) |