Перейти к содержанию

Права доступа и метаданные файлов в Node.js: stat, chmod и симлинки

Источник: theNodeBook — Permissions & Metadata Edge Cases

API метаданных файлов в Node.js отражают факты операционной системы, привязанные к путям и открытым файлам. Речь о битах mode, владельце, метках времени, размере, inode, типе, симлинках и платформенных особенностях. fs.stat() следует по симлинкам. fs.lstat() возвращает данные самой ссылки. chmod() меняет биты mode там, где платформа это поддерживает.

Права доступа и метаданные в Node.js

Объект Stats — снимок метаданных файловой системы в один момент. Устройства, FIFO, сокеты, симлинки, разреженные файлы, /proc и сетевые ФС могут отдавать значения, которые выглядят странно в коде для обычных файлов. Надёжный Node‑код относится к метаданным как к состоянию хоста, которое может измениться между проверкой и операцией.

ls -l показывает что‑то вроде -rw-r--r--. Девять символов кодируют, кто что может делать. Большинство разработчиков бросают взгляд на вывод, гуглят «chmod 755» и идут дальше. Но эти девять бит стоят между процессом Node.js и каждой файловой операцией. Ядро проверяет их при каждом open(). Ошибётесь — приложение упадёт в продакшене с EPERM в три ночи.

Права — лишь часть метаданных. ОС также хранит размер, метки времени, владельца, тип, номер inode и распределение блоков. Всё это отделено от содержимого файла, обычно в структуре порядка 256 байт на ext4. Всё доступно через fs.stat(). Файлы устройств, виртуальные ФС и разреженные файлы делают эти поля менее предсказуемыми, чем кажется коду для обычных файлов.

Модель прав Unix

У каждого файла в Unix девять бит прав, сгруппированных в три тройки:

  • Владелец (user): создатель файла
  • Группа: пользователи, входящие в назначенную группу файла
  • Остальные: все остальные

В каждой группе три бита: чтение ®, запись (w), выполнение (x). Вся модель: три категории, три операции, девять бит.

Биты кодируются восьмеричными цифрами. Каждая цифра — сумма: 4 за чтение, 2 за запись, 1 за выполнение. Значит rwx = 7, rw- = 6, r-x = 5, r-- = 4. Три цифры — полный набор прав. Когда права выглядят как -rw-r--r--, первый символ — тип файла (- обычный, d каталог, l симлинк), остальные девять соответствуют трём восьмеричным цифрам: rw- = 6, r-- = 4, r-- = 4. Это 0o644.

1
2
3
4
const fs = require('fs');
const stats = fs.statSync('./package.json');
const perms = stats.mode & 0o777;
console.log(perms.toString(8)); // "644"

Поле mode — целое число. Старшие биты кодируют тип (обычный файл, каталог, симлинк, устройство). Младшие 9 бит — права. Маска 0o777 оставляет только права, убирая тип.

Если при stat mode равен 33188, в восьмеричном это 0o100644. Биты типа 0o100000 означают «обычный файл». Младшие права — 0o644. Декодировать тип из mode вручную почти не нужно — для этого stats.isFile(), stats.isDirectory() и аналоги.

Частые наборы прав:

  • 0o644 — владелец читает/пишет, остальные только читают. Дефолт для большинства файлов.
  • 0o755 — владелец читает/пишет/выполняет, остальные читают/выполняют. Стандарт для исполняемых и каталогов.
  • 0o600 — только владелец читает/пишет. Приватные ключи, учётные данные.
  • 0o777 — все могут всё. Почти никогда не уместно.

Для каталогов бит execute означает другое: можно ли войти в каталог — открыть файл внутри, сделать cd. Каталог с read без execute позволяет листать имена, но не открыть файл внутри. С execute без read можно открыть файл, если знаете имя, но нельзя получить список. Каталогам обычно дают 0o755.

Многие упускают эту тонкость. Видят каталог 0o744 и думают «владелец полный доступ, все читают». Без execute на каталоге «read» — только список имён. Открыть файл нельзя. cd не сработает. Execute управляет поиском в каталоге.

Как работают права по умолчанию

Когда процесс Node создаёт файл, он не получает 0o666 (чтение/запись для всех). Сначала ОС применяет маску. umask — маска на уровне процесса, которая сбрасывает отдельные биты прав у новых файлов.

На большинстве систем umask по умолчанию 0o022. Математика:

1
2
3
0o666  (дефолт для файлов)
& ~0o022  (инвертировать umask и применить AND)
= 0o644  (rw-r--r--)

Поэтому fs.writeFile() создаёт файлы с 0o644. umask убрал запись у группы и остальных. Для каталогов стартовая точка 0o777, а не 0o666, поэтому umask 0o022 даёт 0o755 — стандарт для каталогов.

Права при создании можно задать явно:

1
fs.writeFileSync('./secret.key', keyData, { mode: 0o600 });

Теперь только владелец может читать и писать. Опция mode задаёт запрошенные права до создания. umask всё равно фильтрует запрос. При umask 0o077 и mode: 0o644 итог — 0o600. Чтобы гарантировать точные права, вызовите fs.chmod() после создания.

process.umask() читает или устанавливает umask процесса, но изменение влияет на все файлы, которые процесс создаст потом. Это глобальная настройка — per‑operation umask нет. Предпочтительнее явный mode на файл. Если читаете umask через process.umask() без аргументов, имейте в виду: это небезопасно для потоков. Внутри Node вызывает C umask(0) для чтения (временно маска 0), затем восстанавливает. В окне с маской 0 возможна гонка с созданием файлов на worker threads и неожиданные права. Поэтому форма без аргументов устарела (DEP0139).

Изменение прав

1
fs.chmodSync('./deploy.sh', 0o755);

После этого у deploy.sh права rwxr-xr-x. Владелец читает, пишет, выполняет. Остальные читают и выполняют. В Unix право на выполнение делает скрипт запускаемым — нет конвенции .exe как в Windows. Написали скрипт, сделали chmod +x — ядро разрешает запуск.

Менять права может только владелец файла (или root). Если процесс Node не владеет файлом, fs.chmod() бросает EPERM. Это жёсткое ограничение ядра, не проверка Node.

1
2
3
4
5
6
7
fs.chmod('./config.json', 0o644, (err) => {
    if (err && err.code === 'EPERM') {
        console.error(
            'Cannot change permissions - not the owner'
        );
    }
});

fs.fchmod() делает то же по открытому дескриптору, а не по пути. Удобно, когда fd уже есть. Есть и promise‑вариант через fileHandle.chmod() в API fs.promises.

Деталь: chmod() обновляет ctime (время изменения метаданных), но не трогает mtime. Меняли метаданные, не содержимое. Для сборочных инструментов, сравнивающих mtime, смена прав не запустит пересборку.

Практичный паттерн в деплой‑скриптах: после распаковки архива или клонирования репозитория пройтись по shell‑скриптам и выставить execute:

1
2
3
4
5
6
const entries = fs.readdirSync('./bin');
for (const entry of entries) {
    if (entry.endsWith('.sh')) {
        fs.chmodSync(`./bin/${entry}`, 0o755);
    }
}

Ещё случай — приватные файлы с секретами. SSH откажется использовать приватный ключ, если он читаем группой или всеми. Приложение может требовать то же:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function checkKeyPermissions(keyPath) {
    const stats = fs.statSync(keyPath);
    const perms = stats.mode & 0o777;
    if (perms & 0o077) {
        throw new Error(
            `${keyPath} has permissions ${perms.toString(
                8
            )}, expected 0600`
        );
    }
}

Маска 0o077 проверяет, есть ли у группы или «остальных» хоть какой‑то доступ. Если бит установлен — файл слишком открыт.

Владение файлом и fs.chown()

У каждого файла числовой владелец (uid) и группа (gid). При открытии ядро проверяет личность по порядку. Совпадение uid — права владельца. Совпадение gid — права группы. Все остальные — права «остальных».

Проверка упорядоченная. Если вы владелец, но права владельца строже, чем у группы, применяются права владельца. Ядро берёт первое совпадение, а не наиболее permissive.

1
2
const stats = fs.statSync('./app.log');
console.log(`uid: ${stats.uid}, gid: ${stats.gid}`);

Свой uid процесса на Unix — process.getuid(). Сопоставление числовых uid с именами — через os.userInfo():

1
2
3
4
5
const os = require('os');
const info = os.userInfo();
console.log(
    `Running as: ${info.username} (uid=${info.uid})`
);

fs.chown() меняет владельца, но в Unix это может только root. Обычные пользователи не передают владение — защита от обхода квот и подмены. На некоторых системах группу можно сменить на другую из ваших групп, но смена uid всегда требует root.

1
fs.chownSync('./app.log', 33, 33); // www-data на Debian/Ubuntu

Это в основном деплой под root, сборка Docker‑образов или setup, создающий файлы под сервисным аккаунтом. В контейнере лог‑каталоги могут создать как root и chown на пользователя приложения перед сбросом привилегий.

Есть fs.lchown() для смены владельца самого симлинка, без перехода к цели. Редко нужно, но API есть.

fs.access(), эффективные UID и проблема TOCTOU

fs.access() проверяет, может ли процесс читать, писать или выполнять файл:

1
2
3
4
5
const { constants } = require('fs');
fs.accessSync(
    './config.json',
    constants.R_OK | constants.W_OK
);

При неудаче — исключение. При успехе вы всё равно можете не открыть файл.

Первая причина — особенность Unix UID. access() смотрит реальные UID/GID процесса. А open() и подобные операции используют эффективные EUID/EGID. При повышенных привилегиях (setuid) или временном сбросе прав fs.access() может успешно отчитаться по real ID, а fs.open() сразу даст EACCES, потому что effective ID не имеет прав.

Вторая причина — гонка TOCTOU (Time of Check, Time of Use). Между access() и open() права могут измениться. Другой процесс сделает chmod. Каталог переименуют. Файл удалят.

Лучше: просто выполнить операцию и обработать ошибку. Не «проверить, потом действовать» — «действовать, потом обработать сбой». Документация Node явно не рекомендует fs.access() перед open(), readFile(), writeFile(). Используйте только когда нужно сообщить доступность без чтения — health check, статус.

ACL в Windows

В Windows нативная модель — списки контроля доступа (ACL). У файла могут быть записи, какие пользователи или группы получают какие права. ACL выражает случаи шире Unix owner/group/others: «пользователь A читает, но не пишет; B пишет, но не удаляет; группа C всё, кроме смены прав».

ACL (Access Control Lists) — структуры данных в Windows и на части Unix‑ФС для управления правами на объекты. ACL содержит несколько ACE (Access Control Entries). Каждая ACE разрешает или запрещает конкретные права (чтение, запись, выполнение, удаление) конкретному пользователю или группе. Так можно дать одному пользователю только чтение, а другому в той же группе — запрет.

Node.js сопоставляет небольшое подмножество Unix‑режимов с атрибутами Windows. 0o444 ставит read‑only. 0o666 снимает. В Windows исполняемость определяется расширением (.exe, .bat, .cmd), бит execute игнорируется.

fs.chown() в Windows есть для совместимости API, но редко полезен. Владение привязано к SID, а не к простым числовым uid. В кроссплатформенном коде опирайтесь на fs.chmod() для read‑only и выносите платформенные нюансы за пределы Node.

1
2
3
if (process.platform !== 'win32') {
    fs.chmodSync('./script.sh', 0o755);
}

Специальные биты прав

Помимо девяти базовых бит есть три специальных в четвёртой восьмеричной цифре:

  • Setuid (4): на исполняемом файле процесс работает с привилегиями владельца файла, а не запустившего. Так passwd меняет /etc/shadow (root), будучи запущенным обычным пользователем.
  • Setgid (2): на исполняемых — привилегии группы файла. На каталогах новые файлы наследуют группу каталога, а не основную группу создателя. Удобно для общих проектных каталогов.
  • Sticky bit (1): на каталогах вроде /tmp запрещает удалять чужие файлы, даже при записи в каталог.

Встречаются в режимах вроде 0o1755 (sticky + обычный executable) или 0o4755 (setuid). Прикладной код редко их выставляет — это администрирование. Но при чтении stats.mode первая цифра может быть не нулём.

Метаданные и объект Stats

fs.stat() делает один syscall и забирает всё, что ОС знает о файле, кроме содержимого. Быстро именно потому, что данные не читаются. stat файла на 100 ГБ занимает микросекунды.

1
2
3
4
const stats = fs.statSync('./data.json');
console.log(stats.size); // байты
console.log(stats.mtimeMs); // последнее изменение (мс)
console.log(stats.mode); // тип + права

У объекта Stats много полей. Что они означают:

  • size — длина содержимого в байтах. Логический размер. У обычного файла совпадает с числом байт. У симлинка (через lstat) — длина строки целевого пути. У каталога — размер структуры каталога на диске. У устройств — 0.
  • mode — тип (старшие биты) + права (младшие 9). Декодируйте через stats.isFile() и т.д. или маской 0o777 для сырых прав.
  • uid, gid — числовой владелец и группа. Имена — через os.userInfo() или системные утилиты.
  • nlink — число жёстких ссылок. Сколько записей каталога указывают на этот inode. Обычный файл начинается с 1. Каталог — с 2 (запись в родителе плюс . внутри).
  • ino — номер inode. Вместе с dev однозначно идентифицирует файл в системе.
  • dev — ID устройства ФС, где лежит файл. На разных mount — разные dev.
  • rdev — для файла устройства: ID представляемого устройства. Для обычных файлов 0.
  • blksize — предпочтительный размер блока I/O. Обычно 4096. Подсказка ФС для оптимальных чанков read/write.
  • blocks — число выделенных 512‑байтных блоков на диске. Может быть меньше size / 512 у разреженных файлов или чуть больше из‑за выравнивания ФС.

Метки времени

Четыре метки отслеживают разные события. Понимание, какая обновляется когда, отделяет рабочий build tool от того, что пересобирает всё каждый раз.

mtime (modification time) обновляется при изменении содержимого. Любая запись сдвигает mtime. Это главная метка для сборки. Инструменты сравнивают mtime исходника и артефакта. Кэши хранят mtime рядом с результатом. Синхронизация сравнивает mtime.

1
2
3
4
5
6
7
8
9
function needsRebuild(src, out) {
    try {
        const srcStat = fs.statSync(src);
        const outStat = fs.statSync(out);
        return srcStat.mtimeMs > outStat.mtimeMs;
    } catch {
        return true;
    }
}

catch аккуратно покрывает два частых случая: если выхода ещё нет, stat(out) даёт ENOENT — нужна пересборка. Если нет исходника, stat(src) падает первым, возвращается true, и ошибка всплывёт на этапе сборки понятнее, чем из проверки времени.

atime (access time) — когда файл последний раз читали. Теоретически. На практике многие Linux монтируют ФС с relatime или noatime, ограничивая обновления atime ради производительности. Обновлять atime на каждое чтение — лишняя запись метаданных. При relatime (частый дефолт) atime обновляется, если текущий atime старше mtime или ctime, или прошло больше 24 часов. При noatime atime не обновляется. На точный atime не полагайтесь.

ctime (change time) обновляется при смене метаданных или содержимого. Переименование, chmod, chown, запись — всё двигает ctime. Напрямую ctime задать нельзя. Ядро управляет им единолично. Поэтому ctime полезен для обнаружения подмены: mtime можно подделать через fs.utimes(), ctime отражает реальное последнее касание.

Ctime часто путают с «временем создания». Это change time. В POSIX «c» — change (состояние inode). До Node v0.12 было путаница: Windows creation time мапили на ctime, и разработчики думали, что ctime везде «создание». Сейчас Node мапит Windows ChangeTime на ctime для согласованности с Unix; настоящее время создания — в birthtime.

birthtime — когда файл создали. Поддерживается на macOS (APFS, HFS+) и на новых ядрах Linux с ext4/XFS. На старых ядрах или ФС без creation time Node вернёт Unix epoch (1 января 1970) или откатится к ctime. Признак отсутствия поддержки: birthtime равен эпохе или совпадает с ctime.

Каждая метка в двух формах: mtime (Date) и mtimeMs (число миллисекунд). Для сравнений удобнее Ms — быстрее и без странностей приведения Date:

1
2
3
if (stats.mtimeMs > cachedMtimeMs) {
    console.log('file changed since last check');
}

fs.utimes() задаёт atime и mtime:

1
2
const now = new Date();
fs.utimesSync('./file.txt', now, now);

Программный аналог touch. Полезно принудить пересборку (поднять mtime), сохранить метки при копировании (скопировать содержимое, затем выставить atime/mtime как у оригинала) или тестировать код, зависящий от времени.

atime и mtime можно поставить в прошлое, настоящее или будущее. ctime и birthtime задать нельзя. ctime всегда отражает реальное изменение метаданных; birthtime неизменен после создания.

Практика в backup:

1
2
3
fs.copyFileSync(src, dest);
const srcStats = fs.statSync(src);
fs.utimesSync(dest, srcStats.atime, srcStats.mtime);

Копия с теми же метками, что оригинал. Иначе atime/mtime были бы «сейчас», и инкрементальный backup решил бы, что файл новый.

BigIntStats

По умолчанию метки времени с точностью до миллисекунды. Наносекунды (редко, но для HFT‑сборок или форензики) — опция { bigint: true } у stat():

1
2
const stats = fs.statSync('./file.txt', { bigint: true });
console.log(stats.mtimeNs); // 1678901234567890123n

Все числовые поля становятся BigInt. Появляются варианты Ns с наносекундами. size, ino, blocks — BigInt. Минус: арифметика BigInt медленнее, смешивать с number без явного преобразования нельзя. Включайте только при реальной нужде в точности.

stat, lstat и fstat

Три варианта для разных задач:

  • fs.stat(path) — следует по симлинкам. Для симлинка — метаданные цели.
  • fs.lstat(path)не следует. Метаданные самой ссылки.
  • fs.fstat(fd) — по открытому дескриптору, не по пути.

Различие lstat важно при работе с симлинками, когда нужна ссылка, а не цель. stat() на симлинк к файлу 1 МБ даст size: 1048576. lstat() на ту же ссылку — size: 12 (длина строки пути). Только lstat() даёт isSymbolicLink() === true.

fstat() удобен, когда файл уже открыт: fd указывает на inode, path resolution не нужен. Чуть быстрее и без гонки, если путь изменится между open() и stat().

Методы проверки типа

У Stats есть методы идентификации типа:

1
2
3
4
5
6
7
stats.isFile(); // обычный файл
stats.isDirectory(); // каталог
stats.isSymbolicLink(); // симлинк (true только через lstat)
stats.isCharacterDevice(); // например /dev/null, /dev/urandom
stats.isBlockDevice(); // например /dev/sda
stats.isFIFO(); // именованный канал
stats.isSocket(); // Unix domain socket

Они декодируют биты типа из mode. Внутри маска S_IFMT и сравнение с S_IFREG, S_IFDIR, S_IFLNK и т.д. Можно и вручную битовыми операциями, но методы яснее и учитывают платформы.

Используйте перед операциями: перед readFile() на произвольном пути — isFile(). Перед рекурсией — isDirectory(). Перед чтением — что это не character device с бесконечным потоком.

Типичный обход каталога:

1
2
3
4
5
6
7
8
9
const entries = fs.readdirSync(dir, {
    withFileTypes: true,
});
for (const entry of entries) {
    if (entry.isFile())
        processFile(path.join(dir, entry.name));
    if (entry.isDirectory())
        recurse(path.join(dir, entry.name));
}

{ withFileTypes: true } возвращает Dirent, а не строки. У Dirent те же методы, что у Stats, без отдельного stat(). На Linux тип часто приходит из readdir через d_type без лишнего syscall. Некоторые ФС отдают DT_UNKNOWN — тогда fallback на lstat().

Ссылки и inode

Путь файла — только имя. Реальная идентичность — inode: структура ФС с метаданными (права, время, размер, указатели на блоки) и уникальным номером. В inode всё о файле, кроме имени и содержимого. Имена живут в каталогах (сами файлы с отображением имя → inode). Содержимое — в блоках данных, на которые указывает inode.

Из‑за разделения путь — цепочка поиска. При открытии /home/user/file.txt ядро идёт по компонентам: inode корня, home в корне, inode home, user, inode user, file.txt, его inode. Путь — цепочка lookup. inode — пункт назначения.

Имя отделено от файла — можно иметь несколько имён на один файл.

Жёсткие ссылки

Жёсткая ссылка — ещё одна запись каталога на тот же inode. Оба имени равноправны. Нет «оригинала» и «копии». Общие содержимое и метаданные — это один файл.

1
2
3
4
5
fs.writeFileSync('a.txt', 'hello');
fs.linkSync('a.txt', 'b.txt');
console.log(
    fs.statSync('a.txt').ino === fs.statSync('b.txt').ino
); // true

Изменение через одно имя видно через другое. appendFileSync('b.txt', ' world') и readFileSync('a.txt') вернёт 'hello world'. Под капотом один inode и один набор блоков. Два имени — две записи каталога на него.

inode хранит nlink — сколько имён указывают на него. Новый файл: nlink 1. Жёсткая ссылка: 2. fs.unlink() убирает одну запись и уменьшает nlink. Данные освобождаются, когда nlink станет 0 и не останется открытых fd на inode.

Тонкость: можно unlink (убрать все имена), но пока процесс держит fd, inode живёт. Данные на диске. Пути нет, fd работает. После закрытия последнего fd ядро освобождает inode и блоки. Unix использует это для временных файлов: создать, открыть, сразу unlink — файл только через fd; при выходе процесса исчезает без мусора в /tmp.

Поэтому fs.unlink() — «unlink», не «delete». Вы снимаете ссылку. Файл может жить под другими именами или через открытые fd.

Ограничения жёстких ссылок: не через границы ФС (EXDEV — cross-device), нельзя на каталоги (циклы в дереве, EPERM).

Backup активно использует hard link: файл не менялся — в сегодняшнем снимке hard link на вчерашнюю копию. Один набор блоков на два снимка. Ротация из 10 бэкапов при стабильных файлах почти не раздувает диск.

Символические ссылки

Симлинк — отдельный файл, чьё содержимое — строка пути. При доступе ФС читает путь и перенаправляет операцию на цель. У симлинка свой inode, не тот же, что у цели.

1
2
fs.symlinkSync('target.txt', 'link.txt');
console.log(fs.readlinkSync('link.txt')); // 'target.txt'

fs.readlink() возвращает сырой путь внутри ссылки без следования. fs.readFile('link.txt') следует и читает цель. fs.lstat('link.txt') — метаданные ссылки; fs.stat('link.txt') — цели.

Симлинки могут пересекать ФС (хранят путь, не номер inode), указывать на каталоги и на несуществующие пути (висячий симлинкENOENT при следовании). Цепочки симлинков ядро разрешает рекурсивно до лимита (обычно 40 в Linux). Кольцо — ELOOP.

1
2
3
fs.symlinkSync('b.txt', 'a.txt');
fs.symlinkSync('a.txt', 'b.txt'); // кольцо
fs.readFileSync('a.txt'); // ELOOP

Симлинк бывает относительным (../data/file.txt от каталога ссылки) или абсолютным (/home/user/data/file.txt — стабилен при переносе ссылки, но не переносим между машинами).

Удалите цель симлинка — ссылка висит. Удалите имя жёсткой ссылки — файл может остаться под другими именами. Симлинк хранит путь и ломается при переносе цели; hard link делит inode и переживает снятие одного имени.

Большинство операций следуют по симлинку прозрачно: stat, readFile, writeFile, chmod, chown. Исключения: lstat — сама ссылка; unlink — удаляет ссылку, не цель; readlink — путь внутри; rename — переименовывает ссылку.

fs.realpath() разворачивает все симлинки в пути и возвращает итоговый абсолютный путь. Удобно для канонического сравнения путей.

Деплой использует симлинки для атомарного переключения версий: v2 в новый каталог, current -> releases/v2, атомарная замена через временный симлинк и rename() поверх старого. Процессы видят либо v1, либо v2, не полусостояние.

Проверка «тот же файл»

Два пути могут указывать на один файл через hard link или симлинки. Проверка:

1
2
3
4
5
function sameFile(p1, p2) {
    const s1 = fs.statSync(p1);
    const s2 = fs.statSync(p2);
    return s1.dev === s2.dev && s1.ino === s2.ino;
}

Должны совпасть и dev, и ino. Номера inode уникальны в пределах одной ФС; на разных mount совпадение ino случайно — уникальна пара dev + ino.

Когда «файлы» — не обычные файлы

В Unix «всё есть файл», и код может встретить пути, похожие на файлы, но ведущие себя иначе. Умение распознать и обработать это спасает от зависаний, исчерпания ресурсов и порчи вывода.

Файлы устройств

В /dev — интерфейсы ядра, не дисковое хранилище. Важные случаи:

/dev/null отбрасывает запись и сразу даёт EOF на чтении. Безопасен с любой операцией. fs.readFile('/dev/null') — пустой буфер. Запись успешна, данные никуда. Частый sink в shell.

/dev/urandom — бесконечный поток криптографически стойких байт из CSPRNG ядра. Конца нет. fs.readFile('/dev/urandom') читает chunk за chunk, растёт heap, пока процесс не упадёт. Частая ловушка для утилит «обработать любой путь».

Нужно читать с лимитом:

1
2
3
4
const handle = await fs.promises.open('/dev/urandom', 'r');
const buf = Buffer.alloc(32);
await handle.read(buf, 0, 32, null);
await handle.close();

Лучше crypto.randomBytes(32) — кроссплатформенно. Внутри OpenSSL RAND_bytes() берёт энтропию из ОС: /dev/urandom на Linux, BCryptGenRandom на современном Windows.

/dev/zero — бесконечные нулевые байты. Та же опасность зависания с readFile(). Иногда для бенчмарков или нулевого заполнения — всегда с явным лимитом байт.

Character devices дают stats.size 0 и isCharacterDevice() === true. Block devices (/dev/sda) — сырой диск, seekable, конечный размер, но чтение — сектора диска, нужен root, и это не то, что ждёт «файловый» процессор.

Обнаружение и отказ:

1
2
3
4
5
if (stats.isCharacterDevice() || stats.isBlockDevice()) {
    throw new Error(
        `Cannot process device file: ${filePath}`
    );
}

Файловая система /proc

/proc на Linux — виртуальная ФС. «Файлы» не на диске — ядро генерирует содержимое при read(). /proc/cpuinfo, /proc/meminfo, /proc/[pid]/status и т.д.

Подвох: stat() часто даёт size: 0. Ядро не знает объём текста до чтения — контент из callback, форматирующего текущие структуры ядра. Код, выделяющий буфер по stats.size, получит ноль:

1
2
const stats = fs.statSync('/proc/cpuinfo');
console.log(stats.size); // 0 — но содержимое есть

fs.readFile() читает chunk’ами до EOF, не доверяя size. Низкоуровневый fs.read() с буфером размера stats.size выделит 0 байт и ничего не прочитает.

Содержимое на момент чтения. Два чтения /proc/meminfo с паузой 100 мс — разные числа. Каждое чтение — свежий снимок, не кэш.

/proc/self — симлинк на /proc/[ваш-pid]/. /proc/[pid]/cmdline — аргументы (байты с \0). /proc/[pid]/fd/ — каталог симлинков на открытые fd. Отладка утечек fd.

/proc только на Linux. macOS — sysctl. В Windows нет аналога в пространстве имён ФС. Ограждайте код:

1
2
3
4
5
6
if (
    process.platform === 'linux' &&
    somePath.startsWith('/proc/')
) {
    return fs.readFileSync(somePath, 'utf8');
}

Именованные каналы (FIFO) и Unix‑сокеты

Именованный канал — IPC, выглядящий как файл. Открытие на чтение блокируется, пока другой процесс не откроет на запись (и наоборот). Утилита обработки файлов на FIFO зависнет в ожидании второй стороны.

Unix domain socket — локальный IPC: Docker (/var/run/docker.sock), PostgreSQL, MySQL, демоны. В stat() похож на файл, но readFile() и createReadStream() не подходят — это endpoint с протоколом.

Проверка:

1
2
3
if (stats.isFIFO() || stats.isSocket()) {
    throw new Error('Cannot process IPC endpoint');
}

Разреженные файлы

Разреженный файл (sparse file) — логические диапазоны байт без выделенных блоков на диске. ФС хранит «дыры» как метаданные и выделяет блоки только при записи ненулевых данных. Чтение из дыры возвращает нули приложению.

У разреженного файла есть «дыры» — логически нули без дискового места. ФС возвращает нули при чтении дыры, блоки не выделяются.

1
2
3
const stats = fs.statSync('disk-image.qcow2');
const logicalSize = stats.size; // 50 ГБ
const actualBytes = stats.blocks * 512; // 2 ГБ

Логически 50 ГБ, на диске 2 ГБ. Остальное — дыры.

Для чтения ведут себя как обычные файлы. Копирование readFileSync + writeFileSync читает нули из дыр и пишет реальные блоки — 2 ГБ на диске раздуваются до 50 ГБ без дыр.

На Linux для копии с учётом разреженности — системный cp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const { spawnSync } = require('node:child_process');
const result = spawnSync('cp', [
    '--sparse=always',
    src,
    dest,
]);
if (result.error || result.status !== 0) {
    throw (
        result.error || new Error(result.stderr.toString())
    );
}

--sparse=always просит cp находить нули и создавать дыры в назначении. Аргументы массивом — пути не проходят через shell.

Признак разреженности — логический размер больше реального на диске:

1
2
3
function isSparse(stats) {
    return stats.blocks * 512 < stats.size;
}

stats.blocks считает 512‑байтные единицы независимо от реального размера блока ФС. Умножение на 512 — реальные байты на диске. Меньше size — есть дыры.

Часто у образов VM (QCOW2, VMDK), файлов БД с предвыделением, overlay контейнеров. Если инструмент копирует произвольные файлы, подумайте про sparse‑awareness.

Безопасная проверка типа файла

Сводка проверок:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function ensureRegularFile(filePath) {
    const stats = await fs.promises.stat(filePath);
    if (stats.isFile()) return stats;
    if (stats.isDirectory())
        throw new Error('Is a directory');
    if (stats.isCharacterDevice() || stats.isBlockDevice())
        throw new Error('Device file');
    if (stats.isFIFO()) throw new Error('Named pipe');
    if (stats.isSocket()) throw new Error('Unix socket');
    throw new Error('Unknown file type');
}

Вызывайте перед операциями, ожидающими обычный файл. Это отсекает зависание на /dev/urandom, блокировку на FIFO, ошибки на каталогах и прочие сюрпризы спецфайлов.

Слой VFS и как на самом деле работает stat()

При вызове fs.stat('./file.txt') цепочка пересекает четыре границы: JavaScript → C++ binding → libuv → ядро. Понимание цепочки объясняет size 0 у части файлов, скорость stat() и единый интерфейс Stats на разных ФС.

fs.stat() в Node идёт в C++ binding, затем uv_fs_stat() в libuv. stat — блокирующая операция ФС, libuv отдаёт её в thread pool; worker делает syscall. На Linux libuv пробует statx(2), на старых ядрах — stat(2). На macOS — stat64. На Windows — GetFileAttributesExW и GetFileInformationByHandle.

VFS (Virtual File System) — слой абстракции ядра с единым интерфейсом к физическим и виртуальным смонтированным ФС. Node вызывает стандартную POSIX‑функцию. VFS маршрутизирует вызов драйверу ext4, NFS, tmpfs, procfs, devtmpfs или другому бэкенду для пути.

Syscall попадает в VFS. VFS даёт единый интерфейс всем смонтированным ФС: ext4, XFS, Btrfs, NFS, procfs, devtmpfs, tmpfs — одни и те же операции сверху. Набор указателей на функции: lookup, getattr, read, write, open, mkdir, unlink и десятки других. Каждый драйвер регистрирует свои реализации.

Кэш dentry (dcache) — структура ядра в RAM для разрешения путей. Путь вроде /home/user/app/config.json превращается в ссылки на inode по компонентам. dcache хранит пары (родительский каталог, имя) → inode, чтобы повторный доступ к тому же пути или родителю не читал каталог с диска.

При stat() VFS сначала разрешает путь через dcache — хеш (родитель, имя) → inode в памяти. Для горячих путей — серия lookup в RAM без диска. Нет dentry — VFS вызывает lookup ФС, читает каталог (или кэш ниже), находит номер inode, загружает inode.

С inode VFS вызывает getattr ФС. ext4 читает ~256 байт inode из таблицы (часто из inode cache). XFS — 512 байт. NFS — RPC на сервер. procfs — диска нет.

Поэтому у /proc size 0. У procfs нет «настоящих» inode с хранимым размером. getattr procfs отдаёт синтетический inode с size = 0: контента ещё нет. Контент появляется в callback при read() — форматирование структур ядра в текст, буфер на время read. До read «содержимое» — указатель на функцию. После закрытия fd буфер освобождается.

devtmpfs (/dev) похож в части случаев. /dev/null и /dev/urandom — character devices с драйверами. stat() даёт реальные метаданные inode на devtmpfs, но size 0: у character device нет хранимого содержимого, данные из read драйвера по запросу.

Абстракция VFS — почему fs.stat() в Node одинаков на ext4, NFS, procfs и devtmpfs. Stats выглядит одинаково; различия проявляются при I/O: обычный файл — стабильный size; /proc — size 0, но есть контент; character device — потенциально бесконечный поток.

Заметка о производительности: stat быстр, потому что читает только метаданные. Ядро держит dentry cache и inode cache в RAM. Для горячих путей stat() часто без диска — ~256 байт inode из кэша, не содержимое файла. Поэтому stat 10 ГБ и 10 байт занимает одно время.

Обратный путь: VFS заполняет struct stat в kernel space, копия в user space (граница контекста), worker libuv упаковывает uv_stat_t, сигнал event loop, C++ binding на главном потоке строит JavaScript Stats. К моменту колбэка метаданные прошли четыре слоя абстракции и стали stats.size, stats.mtimeMs, stats.mode.

Связанное чтение

Комментарии