В модулях Битрикс постоянно приходится читать конфиги, писать отчёты во временную папку, проверять права на upload/ и собирать пути без «склейки через слэш наугад». Старые file_get_contents и ручная конкатенация строк работают, пока не словите обход каталога, битую кодировку Windows или ошибку «Permission denied» без понятного стека. Нормальный контракт ядра — Bitrix\Main\IO: File, Directory, Path и исключения FileNotFoundException / FileOpenException.
Рядом по теме: Cache и TaggedCache, Diag\Logger, Application и Request.
Ниже — практический разбор: как безопасно собирать пути, читать и писать файлы, обходить каталоги, копировать/удалять с проверками и какие ошибки чаще всего ломают продакшн.
Зачем Main\IO, а не «голый» PHP
Три причины использовать обёртки ядра в модуле:
- пути нормализуются через
Path— меньше сюрпризов с.., смешанными/и\; - ошибки превращаются в типизированные исключения D7, а не в молчаливый
false; - один и тот же код удобно тестировать и читать: намерение («создай каталог рекурсивно») видно из имени метода.
Для разового скрипта в local/php_interface иногда проще нативный PHP. Для сервиса модуля, агента или обработчика события — лучше сразу Main\IO.
Path: сборка и нормализация
Класс Bitrix\Main\IO\Path — утилита со статическими методами. Главные:
use Bitrix\Main\IO\Path;
$base = $_SERVER['DOCUMENT_ROOT'] . '/upload/vendor.demo';
$full = Path::combine($base, 'reports', '2026-09.csv');
// → .../upload/vendor.demo/reports/2026-09.csv
$norm = Path::normalize('/upload/../upload/vendor.demo//a.txt');
// убирает лишние сегменты и дубли слэшей
$rel = Path::getRelativePath(
'/var/www/html/upload/vendor.demo/a.txt',
'/var/www/html'
);
// → /upload/vendor.demo/a.txt (зависит от ОС и входных путей)
$ext = Path::getExtension('report.CSV'); // csv
$name = Path::getName('/tmp/report.csv'); // report.csv
Правило безопасности: пользовательский ввод (имя файла из формы, ID из запроса) никогда не конкатенируйте напрямую к корню. Сначала уберите опасные символы, потом Path::combine, потом проверьте, что итоговый путь остаётся внутри разрешённого каталога:
use Bitrix\Main\IO\Path;
use Bitrix\Main\IO\Directory;
use Bitrix\Main\SystemException;
function resolveSafePath(string $rootDir, string $userName): string
{
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $userName);
if ($safe === '' || $safe !== $userName) {
throw new SystemException('Invalid file name');
}
$full = Path::normalize(Path::combine($rootDir, $safe));
$root = Path::normalize($rootDir);
if (strpos($full, $root) !== 0) {
throw new SystemException('Path escape detected');
}
return $full;
}
File: чтение, запись, метаданные
Объект Bitrix\Main\IO\File описывает один файл. Типичный сценарий модуля — записать JSON-отчёт и прочитать его позже:
use Bitrix\Main\IO\File;
use Bitrix\Main\IO\Directory;
use Bitrix\Main\IO\Path;
use Bitrix\Main\Web\Json;
$dir = Path::combine($_SERVER['DOCUMENT_ROOT'], 'upload', 'vendor.demo', 'cache');
Directory::createDirectory($dir);
$path = Path::combine($dir, 'orders.json');
$file = new File($path);
$payload = Json::encode(['updated' => time(), 'ids' => [101, 102]]);
$file->putContents($payload);
if ($file->isExists()) {
$raw = $file->getContents();
$data = Json::decode($raw);
}
$size = $file->getSize();
$mtime = $file->getModificationTime();
$isWritable = $file->isWritable();
Полезные методы:
isExists(),isReadable(),isWritable()— проверки до операций;getContents()/putContents($data)— целиком в память (для больших файлов лучше потоки);open($mode)— получить resource, если нужен построчный разбор;rename($newPath),delete()— переименование и удаление;getExtension(),getFileSize()(алиасы/соседи в разных версиях ядра — смотрите актуальный API).
Для больших CSV не тащите всё через getContents(). Откройте файл и читайте построчно:
use Bitrix\Main\IO\File;
use Bitrix\Main\IO\FileOpenException;
$file = new File($path);
if (!$file->isExists()) {
throw new FileOpenException($path);
}
$fh = $file->open('r');
try {
$header = fgetcsv($fh, 0, ';');
while (($row = fgetcsv($fh, 0, ';')) !== false) {
// обработка строки
}
} finally {
if (is_resource($fh)) {
fclose($fh);
}
}
Directory: создание и обход
Bitrix\Main\IO\Directory умеет создавать дерево каталогов и перечислять содержимое:
use Bitrix\Main\IO\Directory;
use Bitrix\Main\IO\File;
use Bitrix\Main\IO\Path;
$root = Path::combine($_SERVER['DOCUMENT_ROOT'], 'upload', 'vendor.demo', 'inbox');
Directory::createDirectory($root);
$dir = new Directory($root);
if (!$dir->isExists()) {
return;
}
foreach ($dir->getChildren() as $child) {
if ($child instanceof File) {
if (strtolower($child->getExtension()) === 'xml') {
// обработать XML
}
} elseif ($child instanceof Directory) {
// вложенная папка
}
}
Рекурсивное удаление каталога — осторожно, только для своих временных папок модуля:
use Bitrix\Main\IO\Directory;
use Bitrix\Main\IO\Path;
$tmp = Path::combine($_SERVER['DOCUMENT_ROOT'], 'upload', 'vendor.demo', 'tmp', $jobId);
if (is_dir($tmp) && strpos(Path::normalize($tmp), '/upload/vendor.demo/tmp/') !== false) {
Directory::deleteDirectory($tmp);
}
Перед deleteDirectory всегда проверяйте префикс пути. Одна опечатка в переменной — и агент снесёт чужой каталог.
Права и DOCUMENT_ROOT
На BitrixVM и большинстве хостингов веб-пользователь пишет в upload/, но не обязан иметь запись в local/modules/. Держите генерируемые файлы в /upload/<module_id>/ или в каталоге из опции модуля.
use Bitrix\Main\Application;
use Bitrix\Main\IO\Directory;
use Bitrix\Main\IO\Path;
use Bitrix\Main\Config\Option;
$docRoot = Application::getDocumentRoot();
$subdir = Option::get('vendor.demo', 'storage_subdir', 'vendor.demo');
$storage = Path::combine($docRoot, 'upload', $subdir);
Directory::createDirectory($storage);
if (!is_writable($storage)) {
// залогируйте и покажите админу понятную ошибку
}
Не хардкодьте абсолютные пути вида /home/bitrix/www — на другом сервере или в Docker они другие. Берите Application::getDocumentRoot() или $_SERVER['DOCUMENT_ROOT'].
Копирование и атомарная запись
Если файл читают параллельно (агент пишет, страница читает), пишите во временный файл рядом и переименовывайте — так читатель не поймает обрезанный JSON:
use Bitrix\Main\IO\File;
use Bitrix\Main\IO\Path;
function atomicPut(string $targetPath, string $contents): void
{
$dir = Path::getDirectory($targetPath);
$tmp = Path::combine($dir, '.tmp_' . uniqid('', true));
$tmpFile = new File($tmp);
$tmpFile->putContents($contents);
// rename атомарнее на одном разделе ФС
if (!rename($tmp, $targetPath)) {
$tmpFile->delete();
throw new \RuntimeException('Failed to replace ' . $targetPath);
}
}
Для копирования готового файла:
use Bitrix\Main\IO\File;
$src = new File($from);
$dst = new File($to);
if ($src->isExists()) {
// в ряде версий есть copyTo / можно через copy()
copy($src->getPath(), $dst->getPath());
}
Исключения IO
Ловите конкретные классы, а не голый \Exception:
use Bitrix\Main\IO\File;
use Bitrix\Main\IO\FileNotFoundException;
use Bitrix\Main\IO\FileOpenException;
use Bitrix\Main\IO\InvalidPathException;
try {
$file = new File($path);
$data = $file->getContents();
} catch (FileNotFoundException $e) {
// файла нет — штатная ситуация для «ещё не создан»
} catch (FileOpenException $e) {
// есть, но не открылся (права, блокировка)
} catch (InvalidPathException $e) {
// путь кривой
}
В лог модуля пишите путь и код операции, не содержимое файла с персональными данными.
Типичные ошибки
- Конкатенация без Path::combine — получаете
uploadvendorили двойные слэши; на Windows путь может «поехать». - Запись в корень модуля — после обновления модуля файлы пропадают; пишите в
upload/. - Игнор isExists / прав — на проде агент падает раз в сутки, когда tmp очистили или сменили владельца.
- getContents на гигабайтном логе — упираетесь в memory_limit; читайте потоком.
- deleteDirectory без проверки префикса — риск снести лишнее при баге в ID задачи.
- Доверие к имени из $_GET — классический path traversal (
../../bitrix/.settings.php). - Смешение относительных путей — CLI-агент и веб имеют разный cwd; всегда абсолютный путь от document root.
Мини-чеклист в модуле
- Каталог хранения:
upload/<module_id>/..., создать черезDirectory::createDirectory. - Сборка пути: только
Path::combine+ нормализация + проверка префикса. - Запись конфигов/кеша: атомарно (tmp + rename).
- Большие файлы:
open+ поток, неgetContents. - Ошибки:
FileNotFoundException/FileOpenException+ запись в Logger модуля. - Никакого пользовательского имени файла без whitelist-символов.
Заключение
Bitrix\Main\IO не заменяет понимание прав Linux и структуры upload/, но даёт предсказуемый API для модулей: пути через Path, операции через File/Directory, ошибки — через исключения D7. Если в сервисе до сих пор разбросаны голые file_put_contents и строковые слэши — вынесите работу с диском в один класс-хранилище на IO: проще тестировать, проще не словить обход каталога и проще объяснить коллеге, куда модуль вообще пишет.
