← All writeups

Know your filesystem lol...

Know your machine's file system

avatar

Riki Phukon

Β· views

Know your filesystem lol... post image

The Discovery

It started with a seemingly simple error message in my open-source project, Openlgen:

Error: Cross device link not permitted
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 γƒΎ(οΏ£β–½οΏ£)

← All writeups