Rust внутри .NET: как упаковать native-библиотеку в один NuGet-пакет

от автора

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-функцию с StringResult или владением в стиле 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") не пытается искать библиотеку рядом с приложением. Вместо этого:

  1. .NET видит DllImport(“ted_encryption”)

  2. Вызывает зарегистрированный resolver

  3. Resolver определяет ОС

  4. Достает нужный EmbeddedResource

  5. Сохраняет его во временную папку

  6. Загружает через NativeLibrary.Load

  7. 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/