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-функцию с 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 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.

Комментарии (2)


  1. withkittens
    03.06.2026 16:18

    Как будто чересчур переусложнено. https://learn.microsoft.com/en-us/nuget/create-packages/native-files-in-net-packages.

    Python здесь не обязателен, но удобен

    Необходимость тянуть стороннюю технологию/язык - это очень высокая цена. У вас простой скрипт, и раз уже есть зависимость от Rust, напишите его на Rust. Или на C#. Или на тех же Exec тасках, только проверять ОС в Condition.


    1. arnkey Автор
      03.06.2026 16:18

      Согласен, было наваждение на python как язык сборки и билдов :)