FFI, P/Invoke, EmbeddedResource, DllImportResolver и кроссплатформенная доставка без ручного копирования .dll, .so и .dylib.
В примерах ниже используется условная функция шифрования, но статья не про криптографию. Основная тема — FFI, владение памятью и доставка native-бинарей в .NET. Для production-криптографии лучше брать проверенные библиотеки и режимы, а не писать собственный алгоритм.
Зачем это понадобилось
Когда .NET-коду нужно вызвать Rust-библиотеку, первый прототип обычно заводится быстро:
-
Rust собирается как
cdylib; -
функции экспортируются через
extern "C"; -
C# вызывает их через
DllImport; -
результат возвращается через указатель.
Проблемы начинаются позже, когда библиотеку нужно отдать другим командам или использовать в нескольких сервисах.
Под Windows нужен .dll, под Linux — .so, под macOS — .dylib. Кто-то забывает положить файл рядом с приложением, CI собирает не тот target, путь до native-библиотеки отличается на разных окружениях, а ошибка всплывает только в runtime.
Хочется другого сценария:
dotnet add package Ted.Encryption
И чтобы после этого все работало без ручного копирования native-файлов.
В этой статье покажу схему, при которой все native-бинарники упакованы в один NuGet-пакет, а .NET сам выбирает и загружает нужный файл через DllImportResolver.
Что получится в итоге
На уровне пользователя пакет выглядит как обычная .NET-библиотека:
string encrypted = Encryptor.Encrypt("hello", "key-123");string decrypted = Encryptor.Decrypt(encrypted, "key-123");
А внутри происходит вот это:
+------------------+ dotnet add package +-----------------------+| Consumer .NET app | -------------------------------> | NuGet package |+------------------+ +-----------------------+ | | | DllImport("ted_encryption") | v v+------------------+ +-----------------------+| Managed wrapper | | Embedded native files || C# / .NET 8 | | .dll / .so / .dylib |+------------------+ +-----------------------+ | | | P/Invoke | v v+--------------------------------------------------------------------------+| Rust cdylib: extern "C" functions, C-compatible ABI, manual memory owner |+--------------------------------------------------------------------------+
Ключевая мысль: P/Invoke решает вызов функции, но не решает доставку native-бинарей. Доставку решает связка EmbeddedResource + DllImportResolver.
Общая архитектура
Решение состоит из четырех частей:
+----------------------+ +--------------------------+| Rust crate | | .NET wrapper || crate-type = cdylib | ---> | DllImport + safe facade |+----------------------+ +--------------------------+ | | v v+----------------------+ +--------------------------+| Native binaries | ---> | EmbeddedResource || win/linux/macos | | inside .NET assembly |+----------------------+ +--------------------------+ | v +--------------------------+ | DllImportResolver | | extract + NativeLibrary | +--------------------------+
На runtime-пути это выглядит так:
DllImport("ted_encryption") | vNativeLibrary.SetDllImportResolver | vDetect OS and architecture | vExtract embedded native binary | vNativeLibrary.Load(path) | vCall Rust function
1. Rust: C-compatible ABI
C# не может напрямую вызвать Rust-функцию с String, Result или владением в стиле Rust. На границе нужен C-совместимый ABI: примитивы, сырые указатели и явное правило, кто выделяет и кто освобождает память.
Пример:
use std::ffi::{CStr, CString};use std::os::raw::c_char;#[no_mangle]pub extern "C" fn encrypt(input: *const c_char, key: *const c_char) -> *mut c_char { let input = unsafe { CStr::from_ptr(input) }.to_string_lossy().into_owned(); let key = unsafe { CStr::from_ptr(key) }.to_string_lossy().into_owned(); let result = match do_encrypt(&input, &key) { Ok(value) => value, Err(_) => String::new(), }; CString::new(result).unwrap().into_raw()}#[no_mangle]pub extern "C" fn free_string(ptr: *mut c_char) { if ptr.is_null() { return; } unsafe { let _ = CString::from_raw(ptr); }}
Здесь важны два правила.
Первое: у экспортируемых функций должны быть #[no_mangle] и extern "C". Без этого имя символа изменится, и DllImport его не найдет.
Второе: кто выделил память, тот ее и освобождает. Если Rust отдал строку через CString::into_raw, освобождать ее должен Rust через парную функцию вроде free_string. Освобождать такой указатель через Marshal.FreeHGlobal нельзя.
Cargo.toml:
[lib]crate-type = ["cdylib"][dependencies]aes-gcm = "0.10"base64 = "0.22"
2. C#: P/Invoke-обертка
На C#-стороне делаем тонкий native layer и публичный безопасный facade:
using System.Runtime.InteropServices;internal static class Native{ private const string Lib = "ted_encryption"; [DllImport(Lib, EntryPoint = "encrypt", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr Encrypt(string input, string key); [DllImport(Lib, EntryPoint = "free_string", CallingConvention = CallingConvention.Cdecl)] public static extern void FreeString(IntPtr ptr);}public static class Encryptor{ public static string Encrypt(string input, string key) { IntPtr ptr = Native.Encrypt(input, key); try { return Marshal.PtrToStringAnsi(ptr) ?? string.Empty; } finally { Native.FreeString(ptr); } }}
CallingConvention.Cdecl лучше указывать явно. Rust extern "C" использует C calling convention, а неявные значения в interop-коде — хороший способ получить странное поведение на одной ОС и «почему-то работает» на другой.
Еще один нюанс: Marshal.PtrToStringAnsi не равен универсальному UTF-8-решению. Если через границу должны стабильно ходить Unicode-строки, лучше явно договориться о UTF-8 и передавать байты либо использовать соответствующий marshaling. В любом случае Unicode должен быть в тестах.
3. Упаковка native-бинарей в assembly
Теперь основная часть: доставка.
Вместо того чтобы просить пользователя пакета вручную раскладывать .dll, .so и .dylib, добавим их в .NET-сборку как EmbeddedResource.
Пример .csproj:
<ItemGroup> <EmbeddedResource Include="native/win-x64/ted_encryption.dll" LogicalName="ted_encryption.dll" /> <EmbeddedResource Include="native/linux-x64/libted_encryption.so" LogicalName="libted_encryption.so" /> <EmbeddedResource Include="native/osx-x64/libted_encryption.dylib" LogicalName="libted_encryption.dylib" /></ItemGroup>
Получается такая упаковка:
Ted.Encryption.dll|+-- Managed C# wrapper|+-- Embedded resources | +-- ted_encryption.dll +-- libted_encryption.so +-- libted_encryption.dylib
Потребитель видит обычный NuGet-пакет. Native-файлы лежат внутри сборки и достаются только в момент загрузки библиотеки.
4. DllImportResolver: главный трюк
В .NET Core 3.0+ есть механизм NativeLibrary.SetDllImportResolver. Он позволяет перехватить попытку загрузить native-библиотеку и самому решить, откуда ее брать.
Регистрируем resolver один раз:
using System.Reflection;using System.Runtime.InteropServices;internal static class Native{ private const string Lib = "ted_encryption"; static Native() { NativeLibrary.SetDllImportResolver(typeof(Native).Assembly, Resolve); } private static IntPtr Resolve( string libraryName, Assembly assembly, DllImportSearchPath? searchPath) { if (libraryName != Lib) { return IntPtr.Zero; } string path = ExtractNativeLibrary(assembly); return NativeLibrary.Load(path); }}
Теперь выбираем ресурс по платформе и распаковываем его во временную директорию:
private static string ExtractNativeLibrary(Assembly assembly){ string resourceName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ted_encryption.dll" : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "libted_encryption.so" : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "libted_encryption.dylib" : throw new PlatformNotSupportedException(); string directory = Path.Combine(Path.GetTempPath(), "ted_encryption"); Directory.CreateDirectory(directory); string targetPath = Path.Combine(directory, resourceName); if (!File.Exists(targetPath)) { using Stream source = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Resource {resourceName} not found"); using FileStream target = File.Create(targetPath); source.CopyTo(target); } return targetPath;}
После этого DllImport("ted_encryption") не пытается искать библиотеку рядом с приложением. Вместо этого:
-
.NET видит DllImport(“ted_encryption”)
-
Вызывает зарегистрированный resolver
-
Resolver определяет ОС
-
Достает нужный EmbeddedResource
-
Сохраняет его во временную папку
-
Загружает через NativeLibrary.Load
-
P/Invoke вызывает Rust-функцию
Для пользователя пакета все это прозрачно.
5. Автоматизация сборки Rust-бинарей
Чтобы в NuGet не попадали устаревшие native-файлы, сборку Rust можно привязать к dotnet pack или Release-сборке.
Например, через MSBuild target:
<Target Name="BuildRust" BeforeTargets="Build" Condition="'$(Configuration)' == 'Release'"> <Exec Command="python build.py" /></Target>
А в build.py собрать нужные target’ы:
import subprocessTARGETS = { "win-x64": "x86_64-pc-windows-gnu", "linux-x64": "x86_64-unknown-linux-gnu", "osx-x64": "x86_64-apple-darwin",}for output_dir, triple in TARGETS.items(): subprocess.run( ["cargo", "build", "--release", "--target", triple], check=True, ) # Далее: скопировать результат в native/<output_dir>/
Python здесь не обязателен, но удобен: можно одинаково запускать сборку локально и на CI, копировать артефакты, проверять наличие target’ов и публиковать пакет отдельным шагом.
6. WASM из того же Rust-кода
Если Rust-крейт нужен еще и в браузере, его можно собрать под wasm32-unknown-unknown и отдать наружу через wasm-bindgen.
Но FFI-функции с сырыми указателями и WASM API лучше развести через cfg:
#[cfg(target_arch = "wasm32")]#[wasm_bindgen]pub fn encrypt_wasm(input: &str, key: &str) -> String { do_encrypt(input, key).unwrap_or_default()}
Тогда один и тот же core-код может использоваться в нескольких вариантах доставки:
+-- Windows .dll |Rust core ---+-- Linux .so | +-- macOS .dylib | +-- WASM module
Важно: WASM — это отдельный способ доставки. Не стоит пытаться тащить C-style FFI API в браузерную сборку, если для WASM можно дать нормальную функцию с &str.
7. Тесты interop-границы
Interop ломается не там, где приятно. Поэтому тестировать нужно не только happy path.
Минимальный набор:
-
обычная строка;
-
пустая строка;
-
Unicode;
-
неправильный ключ;
-
поврежденный payload;
-
отсутствие native-ресурса;
-
повторный вызов после первой загрузки библиотеки.
Пример xUnit-теста:
public class EncryptorTests{ [Theory] [InlineData("hello", "key-123")] [InlineData("", "key-123")] [InlineData("длинная строка с юникодом", "another-key")] public void RoundTrip_ReturnsOriginal(string text, string key) { string encrypted = Encryptor.Encrypt(text, key); string decrypted = Encryptor.Decrypt(encrypted, key); Assert.Equal(text, decrypted); } [Fact] public void CorruptToken_DoesNotDecryptToOriginal() { string encrypted = Encryptor.Encrypt("secret", "key"); string corrupted = encrypted[..^4]; Assert.NotEqual("secret", Encryptor.Decrypt(corrupted, "key")); }}
Если пакет кроссплатформенный, полезно гонять smoke-тесты на GitHub Actions или другом CI минимум под Windows и Linux. macOS тоже желательно, если она заявлена как поддерживаемая платформа.
Грабли, на которые стоит смотреть
Calling convention. Указывайте CallingConvention.Cdecl явно. Ошибки calling convention особенно неприятны тем, что могут проявляться по-разному на разных платформах.
Владение памятью. Если память выделил Rust, освобождать ее должен Rust. Делайте парные функции вроде free_string.
Кодировка. Не-ASCII строки должны быть в тестах. Если нужен предсказуемый UTF-8, лучше передавать байты и явно фиксировать контракт.
Повторная распаковка. В примере файл кешируется во временной папке. В production можно добавить версионированную директорию или hash, чтобы обновления пакета не конфликтовали со старым extracted-файлом.
Права на выполнение. На Linux/macOS иногда важны права файла после распаковки. Если окружение строгое, проверьте это отдельно.
Архитектура CPU. В статье показаны x64 target’ы. Если нужны arm64 или Alpine/musl, их лучше явно добавить в матрицу сборки и naming convention.
WASM и FFI. Разводите C-style FFI и wasm-bindgen API через cfg, иначе один target начнет мешать другому.
Что получилось
Мы получили схему, в которой:
-
Rust-код собирается в native-библиотеки под несколько ОС;
-
C# вызывает Rust через P/Invoke;
-
память, выделенная в Rust, освобождается на Rust-стороне;
-
native-бинарники упакованы внутрь .NET assembly как
EmbeddedResource; -
DllImportResolverсам выбирает, извлекает и загружает нужную библиотеку; -
потребитель ставит один NuGet-пакет и не раскладывает
.dll,.so,.dylibвручную.
Это не самая очевидная настройка, но после первой сборки она сильно упрощает жизнь: меньше ручных инструкций, меньше runtime-сюрпризов и понятный путь для CI/CD.
Если тема интересна, отдельным продолжением можно разобрать сборку такого же Rust-крейта под WASM и подключение в браузерный frontend.
ссылка на оригинал статьи https://habr.com/ru/articles/1043276/