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")
|
v
NativeLibrary.SetDllImportResolver
|
v
Detect OS and architecture
|
v
Extract embedded native binary
|
v
NativeLibrary.Load(path)
|
v
Call 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 subprocess
TARGETS = {
"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.
Автор: arnkey
