Реклама
Реклама
Реклама
Реклама. Zarub Zarub - зарубежные сервисы, оплата в рублях

Bitrix D7 Main\IO: File, Directory и Path — работа с файлами и типичные ошибки

В модулях Битрикс постоянно приходится читать конфиги, писать отчёты во временную папку, проверять права на 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.

Мини-чеклист в модуле

  1. Каталог хранения: upload/<module_id>/..., создать через Directory::createDirectory.
  2. Сборка пути: только Path::combine + нормализация + проверка префикса.
  3. Запись конфигов/кеша: атомарно (tmp + rename).
  4. Большие файлы: open + поток, не getContents.
  5. Ошибки: FileNotFoundException / FileOpenException + запись в Logger модуля.
  6. Никакого пользовательского имени файла без whitelist-символов.

Заключение

Bitrix\Main\IO не заменяет понимание прав Linux и структуры upload/, но даёт предсказуемый API для модулей: пути через Path, операции через File/Directory, ошибки — через исключения D7. Если в сервисе до сих пор разбросаны голые file_put_contents и строковые слэши — вынесите работу с диском в один класс-хранилище на IO: проще тестировать, проще не словить обход каталога и проще объяснить коллеге, куда модуль вообще пишет.