Know your filesystem lol...
Know your machine's file system
Riki Phukon
Β· views
The Discovery
It started with a seemingly simple error message in my open-source project, Openlgen:
Error: Failed to write LICENSE file:
EXDEV: cross-device link not permitted, rename '/tmp/openlgen-12343r.tmp' -> LICENSE
This error message, while cryptic at first glance, opened up a fascinating journey into understanding how filesystems work at a fundamental level. Let me take you through this journey of discovery, understanding, and solution.
The Context
Openlgen is a CLI tool that generates license files for software projects. One of its core features is atomic file writing - ensuring that license file generation either completely succeeds or fails without leaving partial files. The implementation looked straightforward:
async function writeFileAtomically(filepath: string, content: string) {
const tempPath = join(os.tmpdir(), `openlgen-${randomUUID()}.tmp`);
try {
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filepath); // π₯ This is where things got interesting
} catch (error) {
await fs.unlink(tempPath).catch(() => {});
throw error;
}
}
The strategy was simple:
- Write content to a temporary file
- Rename it to the final destination
- Clean up on failure
The Problem
The bug emerged when users tried to generate license files on systems where /tmp
was mounted on a different filesystem than their project directory. This is actually quite common in Unix-like systems, where /tmp
is often mounted as a separate filesystem for security and performance reasons.
The error message EXDEV: cross-device link not permitted
was the kernel's way of saying: "You can't perform a rename operation across filesystem boundaries."
The Deep Dive
To understand why this happens, we need to understand how filesystems work at a lower level.
Filesystem Architecture 101
In Unix-like systems, a filesystem is more than just a collection of files and directories. It's a complex data structure that manages:
- Inodes: Unique identifiers for files that contain:
- File metadata (permissions, timestamps, etc.)
- Pointers to actual data blocks
- Directory Entries: Mappings between human-readable names and inodes
- Data Blocks: The actual content of files
When you perform a rename operation within the same filesystem, it's remarkably efficient. The system only needs to:
- Update directory entries to point to different inodes
- No actual data movement is required
- The operation is atomic (all-or-nothing)
Before rename:
/tmp/
βββ tempfile (inode #1234) β [Data Blocks]
After rename:
/project/
βββ LICENSE (inode #1234) β [Same Data Blocks]
The Cross-Filesystem Challenge
However, when /tmp
and your project directory are on different filesystems:
Filesystem A (/tmp): Filesystem B (/project):
βββ Different inode table βββ Separate inode table
βββ Different block allocation βββ Different block management
βββ Separate metadata structures βββ Independent space tracking
A simple rename becomes impossible because:
- Inodes are filesystem-specific
- Data blocks are managed independently
- Metadata structures don't span across filesystems
The Solution
Understanding this, the fix became clear. We needed to handle the cross-filesystem scenario explicitly:
async function writeFileAtomically(filepath: string, content: string) {
const tempPath = join(os.tmpdir(), `openlgen-${randomUUID()}.tmp`);
try {
await fs.writeFile(tempPath, content, 'utf8');
try {
await fs.rename(tempPath, filepath);
} catch (error) {
if (error.code === 'EXDEV') {
// Handle cross-filesystem scenario
await fs.copyFile(tempPath, filepath);
await fs.unlink(tempPath);
} else {
throw error;
}
}
} catch (error) {
await fs.unlink(tempPath).catch(() => {});
throw error;
}
}
This solution:
- Attempts the fast path (rename) first
- Falls back to copy+unlink when needed
- Maintains atomicity through careful error handling
- Cleans up temporary files in all scenarios
Assumptions Are Dangerous: I initially assumed all file operations would work the same way everywhere.
Check out Openlgen γΎ(οΏ£β½οΏ£)