# Know your filesystem lol...

> Know your machine's file system
- **Published**: 2025-02-02
- **URL**: https://rikiphukon.com/blog/file-system

---

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

<Image width={600} height={400} src="/blog/file-system/error.png" caption="Error: Cross device link not permitted"/>

```sh
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:

```typescript
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)

```sh
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:
```sh
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:

```typescript
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](https://github.com/phukon/openlgen) ヾ(￣▽￣)
---
- [All articles](https://rikiphukon.com/blog)