Программирование RustUnsafe и памятьИнженер по системному программированию на Rust

Каким условием безопасная обёртка над FFI вызовом может скрыть unsafe от вызывающего кода Rust?

Каким условием безопасная обёртка над FFI-вызовом может скрыть unsafe от вызывающего кода Rust?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Безопасная обёртка может скрыть unsafe только тогда, когда она сама полностью обеспечивает все инварианты, требуемые FFI-вызовом. Пользователь безопасного API не должен иметь способа передать некорректные данные, нарушить время жизни, вызвать функцию в недопустимом состоянии или обойти проверку контракта.

Простое размещение unsafe внутри функции ничего не гарантирует: ответственность переносится на автора обёртки, но не исчезает.

Исторический контекст

Rust разделяет безопасный и небезопасный код, чтобы локализовать операции, которые нельзя полностью проверить компилятору: работу с raw pointers, вызовы внешнего кода и ручное управление памятью. Такой подход позволяет взаимодействовать с системными библиотеками, не отказываясь от проверок в остальной части программы.

Изначальная практическая проблема FFI заключается в несовпадении моделей безопасности. Rust проверяет часть инвариантов на этапе компиляции, а C-библиотека обычно ожидает, что вызывающий сам обеспечит корректность указателей, размеров, времени жизни и состояния объектов.

Постановка проблемы

Пусть C-функция принимает указатель на данные и длину, читает память во время вызова и возвращает код результата. Сам вызов требует, чтобы указатель был ненулевым либо корректно обрабатывался C-кодом, диапазон памяти был действительным, длина соответствовала выделенному объекту, а библиотека не использовала указатель после возврата.

Если безопасная Rust-функция просто передаст произвольные значения в такой вызов, её безопасный интерфейс будет ложным. Ошибка пользователя приведёт не к обычному Result::Err, а к неопределённому поведению: чтению за границами объекта, обращению к освобождённой памяти или нарушению требований внешней библиотеки.

Подробное решение

Автор обёртки должен преобразовать проверяемые Rust-типы и условия вызова в полный контракт FFI. Для этого он проверяет или конструктивно обеспечивает указатели, размеры, выравнивание, инициализацию, время жизни, допустимые значения перечислений, ограничения потоков и правила владения результатом.

Ключевой принцип: каждый безопасный вход обёртки должен приводить только к состоянию, в котором unsafe-операция допустима. Если какое-либо обязательное условие нельзя проверить или гарантировать внутри функции, оно должно быть частью явно небезопасного интерфейса, а не скрытой предпосылкой безопасного вызова.

Например, обёртка может принимать &[u8] вместо raw pointer и длины. Это гарантирует наличие непрерывного и инициализированного диапазона на время вызова, но не доказывает, что C-функция не сохранит указатель. Если библиотека может сохранить его, обёртке потребуется отдельная модель владения и времени жизни; иначе функция не должна выдавать безопасный интерфейс.

Обёртка также обязана учитывать фактический контракт C-библиотеки, а не только типы Rust. Компилятор Rust не проверяет, что C-код действительно ограничивается переданной длиной, не освобождает память неправильным аллокатором и не обращается к указателю после возврата.

Безопасная абстракция должна сохранять корректность при любом допустимом использовании её публичного API. Поэтому проверки нельзя оставлять только в вызывающем коде, комментариях или тестах: любой будущий вызов функции должен проходить через тот же защищённый путь.

Ситуация из практики

Команда оборачивает C-библиотеку, которая принимает строку, читает её только во время вызова и возвращает целочисленный статус. Первый вариант принимает raw pointer и длину, помечает функцию Rust как безопасную и предполагает, что вызывающий сам передаст корректные значения. Он прост, но фактически экспортирует требования C-контракта наружу без механизма их проверки.

Второй вариант принимает Rust-строку, внутри создаёт представление, совместимое с ожиданиями C, проверяет невозможные для C значения и вызывает внешнюю функцию в локальном unsafe-блоке. Его недостаток — дополнительные проверки и возможное копирование, зато безопасный вызывающий код не управляет raw pointer и не обязан знать детали ABI.

Третий вариант оставляет API небезопасным, если библиотека может сохранять указатель или требует внешней синхронизации. Это честное решение: такие условия могут зависеть от архитектуры программы и не всегда выражаются обычными Rust-типами.

Выбранным решением становится безопасная обёртка только для режима, в котором строка используется C синхронно и не сохраняется. Для режимов с сохранением указателя предоставляется отдельный API с явной моделью владения либо unsafe-функция с документированными требованиями. В результате граница доверия остаётся узкой, а безопасный интерфейс не обещает больше, чем реально гарантирует библиотека.

Что кандидаты часто упускают

1. Достаточно ли спрятать единственный unsafe-блок в теле безопасной функции?

Нет. Безопасность определяется не расположением unsafe, а доказанностью всех его предпосылок. Если функция принимает параметры, из которых можно построить недействительный указатель, неверный размер или запрещённое состояние библиотеки, то внутренний unsafe-блок лишь скрывает проблему от компилятора и пользователя.

Нужно либо ограничить входные типы так, чтобы некорректное состояние нельзя было выразить, либо выполнить проверки внутри обёртки, либо сделать саму функцию unsafe и описать обязательства вызывающего кода.

2. Может ли Rust-компилятор проверить, что C-функция соблюдает свой контракт?

Нет. Компилятор проверяет только Rust-код и типовую сторону объявления FFI. Он не доказывает, что реализация C не выходит за переданный диапазон, не сохраняет указатель, не вызывает callback в неожиданный момент и соблюдает требования к потокам или глобальному состоянию.

Поэтому корректность безопасной обёртки зависит не только от Rust-проверок, но и от достоверного контракта внешней библиотеки. При сомнительном или изменяемом контракте безопасный API должен быть консервативнее либо оставаться небезопасным.

3. Когда безопасная обёртка обязана отказаться от скрытия FFI-контракта?

Когда её корректность зависит от действий, которые невозможно заставить выполнить типами или проверками внутри обёртки. Примеры — внешний код должен самостоятельно удерживать переданный ресурс, вызывать функции только под определённой блокировкой или гарантировать, что библиотека не использует указатель после возврата.

В таком случае следует явно выразить ответственность в unsafe-интерфейсе и документации либо спроектировать отдельный владеющий объект, который сам удерживает ресурс и синхронизацию. Скрытие таких требований под безопасной функцией создало бы ложную гарантию и могло бы превратить корректную ошибку использования в неопределённое поведение.