n7n-plugin-host 0.2.0

wasmtime-бекенд для crate::registry::PluginRegistry (n7n-harness) — друга (wasm-компонентна) реалізація слотів detector/verify/ast_facts/t0, поруч з in-process-бекендом §3.12 пункту 16а
Documentation
//! Наскрізний тест wasmtime-бекенда — доккоментар `src/lib.rs`, розділ
//! "Тестовий guest".
//!
//! # Чому тест може себе пропустити
//!
//! `tests/guest/{detector_fixture,t0_fixture}` — окремі, НЕ
//! workspace-member `cargo`-проєкти (власний `[workspace]`, доккоментар
//! їхніх `Cargo.toml`), які цей файл компілює для `wasm32-wasip3` ПІД ЧАС
//! прогону тесту (`build_fixture` нижче): жодного `.wasm` не закомічено в
//! репозиторій. Вимоги середовища тут ВИЩІ, ніж були для p2: потрібен
//! `nightly` з компонентом `rust-src` (таргет tier-3, std будується
//! `-Z build-std`) і wasi-sdk 34-rc.3+ у `WASI_SDK_PATH` (wasi-libc для
//! p3 rustup не постачає) — деталі в доккоментарі `build_fixture`. Якщо
//! чогось із цього немає чи немає мережі до crates.io (guest тягне
//! `wit-bindgen` при першій компіляції) — `build_fixture` друкує причину
//! в stderr і повертає `None`; кожен тест нижче на це реагує раннім
//! `return`, а НЕ панікою: `cargo test --workspace --all-features` мусить
//! лишатись зеленим і в CI-образі без цього target-у. У середовищі, де
//! писався цей крейт, і target, і мережа були — тест ДІЙСНО виконує
//! наскрізний прогін (компіляція, інстанціювання, виклик, перевірка
//! відсутності прав запису), не лише падає в компільований бекенд.
//!
//! # Чому `#[test]` + [`block_on`], а не `#[tokio::test]`
//!
//! Історично причина була в тому, що `p2::add_to_linker_sync` реалізує
//! синхронний WASI-міст через власний внутрішній рантайм, і виклик
//! компонента ВСЕРЕДИНІ вже запущеного tokio-рантайму падав панікою
//! "Cannot start a runtime from within a runtime". Після переходу на p3
//! ця причина зникла (жодного вкладеного рантайму більше немає — p3
//! async наскрізь), але [`block_on`] лишається свідомо: він не тягне
//! tokio в dev-залежності тесту заради того, щоб покрутити один future,
//! і не приховує від нас, чи future взагалі колись повертає `Pending`.
//!
//! На відміну від часів p2, тепер future РЕАЛЬНО можуть віддавати
//! `Pending` (async-виклик компонента), тож [`block_on`] крутить
//! busy-loop із `yield_now` — цього достатньо, бо `Wasm*::into_*_fn`
//! нікуди не паркують waker: єдиний, хто рухає цей future, — ми самі.

use std::future::Future;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

use harness::registry::PluginRegistry;
use llm_lib::attempt::VerifyReport;
use llm_lib::edit_plan::{AnchoredEdit, EditPlan, FileEditPlan};
use plugin_host::{WasmDetector, WasmT0};

fn noop(_: *const ()) {}
fn clone_noop(_: *const ()) -> RawWaker {
    RawWaker::new(std::ptr::null(), &VTABLE)
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_noop, noop, noop, noop);

/// Доккоментар модуля, "Чому `#[test]` + `block_on`, а не
/// `#[tokio::test]`".
fn block_on<F: Future>(mut fut: F) -> F::Output {
    // SAFETY: RawWaker з no-op vtable — жодного стану, жодного UB (той
    // самий патерн, що `futures::task::noop_waker`, тут без нової
    // залежності заради однієї функції).
    let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
    let mut cx = Context::from_waker(&waker);
    // SAFETY: `fut` не рухається після цього — `Pin::new_unchecked` на
    // локальній змінній, яку ми більше не переміщуємо, той самий патерн,
    // що ручні executors без `Box::pin`.
    let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
    loop {
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(v) => return v,
            Poll::Pending => std::thread::yield_now(),
        }
    }
}

/// Компілює `tests/guest/<name>` для `wasm32-wasip3` (`release`, щоб
/// мінімізувати розмір і час) і повертає шлях до готового `.wasm`, або
/// `None` з поясненням у stderr, якщо середовище не тягне (доккоментар
/// модуля).
///
/// # Чому збірка складніша за один `cargo build --target`
///
/// `wasm32-wasip3` — tier-3-таргет: prebuilt-артефактів для нього немає
/// ні на stable, ні на nightly (`rustup target add wasm32-wasip3`
/// відмовляє на обох), тож std доводиться будувати з джерел
/// (`-Z build-std`, звідси `+nightly` і компонент `rust-src`). А wasi-libc
/// для p3 rustup не постачає взагалі — її дає лише wasi-sdk, шлях до
/// якого цей харнес бере з `WASI_SDK_PATH`.
///
/// `crt1-reactor.o` у лінк-лінії — НЕ формальність і не «про людське
/// око». Саме цей об'єкт робить визначеними
/// `__wasm_{g,s}et_{stack_pointer,tls_base}`
/// (`libc-bottom-half/crt/wasip3_symbol_references.h` у wasi-libc:
/// «Force … to exist as defined symbols», «necessary to get linking to
/// work»). Без нього лінкування падає сотнями `undefined symbol`, і жоден
/// `-L` цього не рятує — шлях пошуку бібліотек не додає об'єктний файл до
/// лінкування. `-C link-self-contained=no` тут обов'язковий парний крок:
/// без нього rustc намагається підкласти власний (неіснуючий для цього
/// таргета) startup-об'єкт.
///
/// Guest — саме РЕАКТОР (`cdylib`-компонент без `main`), тому
/// `crt1-reactor.o`, а не `crt1-command.o`.
fn build_fixture(name: &str) -> Option<PathBuf> {
    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let fixture_dir = manifest_dir.join("tests/guest").join(name);
    let manifest_path = fixture_dir.join("Cargo.toml");

    let Some(sdk) = std::env::var_os("WASI_SDK_PATH") else {
        eprintln!(
            "wasmtime_backend: пропускаю наскрізний тест — не задано WASI_SDK_PATH. \
             `wasm32-wasip3` потребує wasi-libc, якої rustup не постачає (доккоментар \
             build_fixture); візьми wasi-sdk 34-rc.3+ і вкажи корінь у цій змінній"
        );
        return None;
    };
    let sysroot = PathBuf::from(&sdk).join("share/wasi-sysroot/lib/wasm32-wasip3");
    let crt = sysroot.join("crt1-reactor.o");
    if !crt.is_file() {
        eprintln!(
            "wasmtime_backend: пропускаю наскрізний тест — {} не знайдено; \
             WASI_SDK_PATH вказує на SDK без p3-сисрута (потрібен wasi-sdk 34-rc.3+)",
            crt.display()
        );
        return None;
    }

    let rustflags = format!(
        "-C link-self-contained=no -L {} -C link-arg={}",
        sysroot.display(),
        crt.display()
    );

    let status = Command::new("cargo")
        .env("RUSTFLAGS", &rustflags)
        .args([
            "+nightly",
            "build",
            "-Z",
            "build-std=std,panic_abort",
            "--target",
            "wasm32-wasip3",
            "--release",
            "--manifest-path",
        ])
        .arg(&manifest_path)
        .status();

    match status {
        Ok(status) if status.success() => {}
        Ok(status) => {
            eprintln!(
                "wasmtime_backend: пропускаю наскрізний тест — `cargo +nightly build --target \
                 wasm32-wasip3` для {name} завершився кодом {status:?} (немає nightly чи \
                 компонента `rust-src`, немає мережі до crates.io, чи інша причина \
                 середовища — доккоментар модуля, розділ «Чому тест може себе пропустити»)"
            );
            return None;
        }
        Err(err) => {
            eprintln!(
                "wasmtime_backend: пропускаю наскрізний тест — не вдалось спавнити cargo: {err}"
            );
            return None;
        }
    }

    let wasm_path = fixture_dir
        .join("target/wasm32-wasip3/release")
        .join(format!("{name}.wasm"));
    if !wasm_path.is_file() {
        eprintln!(
            "wasmtime_backend: пропускаю — {} не з'явився після успішного build",
            wasm_path.display()
        );
        return None;
    }
    Some(wasm_path)
}

// --- detector: instance-per-call + write denied -----------------------

/// Дві гарантії одним прогоном: свіжий інстанс на КОЖЕН виклик (§3.16
/// правило 3) і відсутність у guest-а прав запису (§3.9).
///
/// # Чому запис тут повертає помилку, а не вбиває інстанс
///
/// На WASI p3 будь-який `wasi:filesystem` `open-at` трапить компонент —
/// зламано в `wasmtime-wasi` 48, і не лише для запису (читання наявного
/// файлу теж). Цей тест зелений не всупереч тому дефекту, а тому, що хост
/// не видає guest-ові ЖОДНОГО preopen
/// (`plugin_host::wasi_ctx::no_filesystem_ctx`): libc guest-а відмовляє на
/// резолві шляху, не доходячи до зламаного `open-at`, і помилка виходить
/// звичайна.
///
/// Тобто перевіряється саме те, що заявлено: guest не має файлової
/// capability. Якби preopen видавався, цей тест впав би трапом — і саме це
/// й сталось під час міграції на p3, доки шлях читання не перевели на
/// вужчий `host-fs`.
#[test]
fn detector_denies_write_and_uses_a_fresh_instance_per_call() {
    let Some(wasm_path) = build_fixture("detector_fixture") else {
        return;
    };
    let repo_root = tempfile::tempdir().expect("tempdir");

    let detect_fn = WasmDetector::from_file(repo_root.path(), &wasm_path)
        .expect("WasmDetector::from_file")
        .into_detect_fn();

    // Той самий `Arc`-замикання (одна інʼєкція) викликається ДВІЧІ — якщо
    // wasmtime кешував `Instance` між викликами, другий виклик побачив би
    // `calls=2` (лінійна памʼять guest-а пережила б перший виклик).
    let first = block_on((detect_fn)()).expect("перший виклик detect()");
    let second = block_on((detect_fn)()).expect("другий виклик detect()");

    assert_eq!(first.len(), 1);
    assert_eq!(second.len(), 1);
    assert!(
        first[0].message.starts_with("calls=1;"),
        "перший виклик: {}",
        first[0].message
    );
    assert!(
        second[0].message.starts_with("calls=1;"),
        "другий виклик мусив побачити СВІЖИЙ інстанс (calls=1, не 2) — \
         інакше wasmtime-бекенд закешував Instance між викликами, порушуючи \
         §3.16 правило 3: {}",
        second[0].message
    );

    for report in [&first[0], &second[0]] {
        assert!(
            report.message.contains("write: denied"),
            "guest не мав отримати файлової capability: {}",
            report.message
        );
        assert!(
            !report.message.contains("BUG"),
            "запис МАВ провалитись — guest не має жодного preopen: {}",
            report.message
        );
    }

    // І головне: на диску нічого не з'явилось.
    assert!(
        !repo_root.path().join("pwned.txt").exists(),
        "guest створив файл — межа §3.9 не спрацювала"
    );
}

/// Той самий wasm-детектор, підключений через `PluginRegistry` — доказ
/// "друга реалізація тих самих слотів" (доккоментар `src/lib.rs`):
/// `PluginRegistry::new`/`checkout_detector` не відрізняють wasm-замикання
/// від in-process.
#[test]
fn wasm_detector_plugs_into_plugin_registry_like_the_in_process_backend() {
    let Some(wasm_path) = build_fixture("detector_fixture") else {
        return;
    };
    let repo_root = tempfile::tempdir().expect("tempdir");

    let detect_fn = WasmDetector::from_file(repo_root.path(), &wasm_path)
        .expect("WasmDetector::from_file")
        .into_detect_fn();

    let verify_fn: harness::registry::VerifyFn = Arc::new(|| {
        Box::pin(async {
            VerifyReport {
                ok: true,
                output: String::new(),
                infra_error: false,
            }
        })
    });

    let registry = PluginRegistry::new(detect_fn, verify_fn, None, None);

    let checkout_1 = registry.checkout_detector();
    let checkout_2 = registry.checkout_detector();
    assert_eq!(
        checkout_2.generation,
        checkout_1.generation + 1,
        "реєстр і далі бампає generation на кожен checkout — незалежно від бекенда"
    );

    let violations = block_on((checkout_1.value)()).expect("detect()");
    assert_eq!(violations.len(), 1);
    assert_eq!(violations[0].file, PathBuf::from("src/a.rs"));
}

// --- t0: план роундтрипить через bindgen! в обидва боки ----------------

#[test]
fn t0_end_to_end_returns_the_fixed_edit_plan() {
    let Some(wasm_path) = build_fixture("t0_fixture") else {
        return;
    };
    let repo_root = tempfile::tempdir().expect("tempdir");

    let plan_fn = WasmT0::from_file(repo_root.path(), &wasm_path)
        .expect("WasmT0::from_file")
        .into_t0_fn();

    let plan = block_on((plan_fn)());

    let expected = EditPlan {
        files: vec![FileEditPlan::Anchored {
            path: PathBuf::from("src/a.rs"),
            edits: vec![AnchoredEdit {
                anchor: "1a2".to_string(),
                line: 3,
                new_text: Some("fixed by t0".to_string()),
            }],
        }],
    };
    assert_eq!(
        plan, expected,
        "guest-план мусить пережити roundtrip host ← guest bindgen! незмінним"
    );
}