Skip to main content

plugin_host/
detector.rs

1//! Wasm-бекенд поверхні `detector` (`wit/detector.wit`, world
2//! `detector-plugin`) — друга реалізація [`harness::pipeline::DetectFn`]
3//! поруч з in-process-замиканням, яке `harness::registry::PluginRegistry`
4//! уже приймає (пункт 16а). [`WasmDetector::into_detect_fn`] — точка
5//! підключення: `PluginRegistry::new`/`replace_detector` беруть її
6//! результат так само, як `Arc::new(|| ...)`.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
12use wasmtime::{Engine, Store};
13
14use harness::pipeline::{DetectFn, Violation};
15
16use crate::engine::new_engine;
17use crate::host_state::HostState;
18use crate::wasi_ctx::no_filesystem_ctx;
19
20// `wasmtime::component::bindgen!` — host-сторона генератора WIT-байндінгів
21// живе В САМОМУ `wasmtime` під фічею `component-model` (задача, розділ
22// "Залежності `plugin-host`"): окремого `wit-bindgen` у `Cargo.toml` не
23// треба. `path` — відносно `CARGO_MANIFEST_DIR` цього крейта
24// (`crates/plugin-host`), тобто корінь `wit/` репозиторію напряму: жодного
25// дубльованого `wit/` усередині `plugin-host` — контракт лишається одним
26// джерелом правди (пункт 16а, doc-коментар `wit/types.wit`).
27//
28// Кожна з чотирьох поверхонь (`detector`/`verify`/`ast_facts`/`t0`) робить
29// СВІЙ `bindgen!` у СВОЄМУ приватному `mod bindings` — одна макро-
30// інвокація на файл, а не одна спільна на весь world-набір: `bindgen!`
31// генерує одну структуру світу на виклик (тут — `DetectorPlugin`), і
32// чотири світи цього пакета експортують РІЗНІ інтерфейси (`detector` проти
33// `verify` проти...), тож спільний виклик однаково довелось би повторити
34// чотири рази з різним `world:`.
35mod bindings {
36    wasmtime::component::bindgen!({
37        path: "wit",
38        world: "detector-plugin",
39        // WASI p3 побудована на `component-model-async`, і синхронного
40        // лінкера в `wasmtime_wasi::p3` не існує — тож і згенерований
41        // виклик, і хостові імпорти мусять бути async (доккоментар
42        // `engine.rs`).
43        imports: { default: async },
44        exports: { default: async },
45    });
46}
47
48/// Дзеркало `record violation` (`wit/types.wit`) → [`Violation`] —
49/// РЕАЛЬНИЙ тип, який `bindgen!` вивів із `.wit`-файлів (на відміну від
50/// ручного дзеркала `crates/harness/src/registry.rs::wit_conformance`,
51/// яке лише перевіряє ФОРМУ; тут — фактичний конвертер типу, який
52/// `wasmtime` згенерував сам). Якщо `record violation` і
53/// `harness::pipeline::Violation` розійдуться полями, це впаде тут
54/// компіляцією, не тестом.
55impl From<bindings::exports::n7n::fix_deps::detector::Violation> for Violation {
56    fn from(v: bindings::exports::n7n::fix_deps::detector::Violation) -> Self {
57        Violation {
58            file: PathBuf::from(v.file),
59            line: v.line.map(|l| l as usize),
60            message: v.message,
61        }
62    }
63}
64
65/// Реалізація вужчого `host-fs`-імпорту (`wit/host.wit`) для поверхні
66/// `detector` — читання ЗАВЖДИ, запису немає в самій сигнатурі
67/// (`host-fs.read-file` не має аналога "write"). Кожен bindgen!-модуль
68/// генерує СВІЙ окремий трейт `Host` (чотири номінально різних типи з
69/// однаковою формою — bindgen! не дедуплікує їх між макро-інвокаціями),
70/// тож ця імплементація повторюється в кожному з чотирьох файлів
71/// (`verify.rs`/`ast_facts.rs`/`t0.rs`) з тим самим однорядковим тілом.
72impl bindings::n7n::fix_deps::host_fs::Host for HostState {
73    async fn read_file(&mut self, path: String) -> Result<String, String> {
74        std::fs::read_to_string(self.repo_root.join(path)).map_err(|e| e.to_string())
75    }
76}
77
78/// Wasm-бекенд одного `detector`-компонента: скомпільований
79/// [`Component`]/[`Engine`]/[`Linker`] — кешовані (доккоментар crate-level
80/// "інстанс на спробу, без кешу" — кешується лише СКОМПІЛЬОВАНИЙ
81/// компонент, не `Instance`), плюс корінь репозиторію для read-only
82/// preopen-у.
83pub struct WasmDetector {
84    engine: Engine,
85    component: Component,
86    linker: Linker<HostState>,
87    repo_root: PathBuf,
88}
89
90impl WasmDetector {
91    /// Завантажує й компілює `.wasm`-компонент, лінкує WASI p3 (§3.9: guest
92    /// на `wasm32-wasip3` фактично потребує `wasi:cli`/`wasi:filesystem`/
93    /// `wasi:random`/`wasi:clocks` навіть якщо власний `wit/detector.wit`
94    /// їх не імпортує напряму — рантайм `std` для цієї цілі лінкує їх
95    /// безумовно) і власний вужчий `host-fs`.
96    pub fn from_file(
97        repo_root: impl Into<PathBuf>,
98        wasm_path: impl AsRef<Path>,
99    ) -> wasmtime::Result<Self> {
100        let engine = new_engine()?;
101        let component = Component::from_file(&engine, wasm_path.as_ref())?;
102        let mut linker = Linker::<HostState>::new(&engine);
103        wasmtime_wasi::p3::add_to_linker(&mut linker)?;
104        bindings::n7n::fix_deps::host_fs::add_to_linker::<_, HasSelf<HostState>>(
105            &mut linker,
106            |s| s,
107        )?;
108        Ok(Self {
109            engine,
110            component,
111            linker,
112            repo_root: repo_root.into(),
113        })
114    }
115
116    /// Один прогін: свіжий `Store`+`WasiCtx`+`Instance` — жодного поля
117    /// цього типу не переживає виклик, тож наступний виклик фізично не
118    /// може успадкувати стан попереднього (доккоментар crate-level).
119    async fn call(&self) -> wasmtime::Result<Result<Vec<Violation>, String>> {
120        let wasi = no_filesystem_ctx();
121        let mut store = Store::new(
122            &self.engine,
123            HostState {
124                wasi,
125                table: ResourceTable::new(),
126                repo_root: self.repo_root.clone(),
127            },
128        );
129        let instance =
130            bindings::DetectorPlugin::instantiate_async(&mut store, &self.component, &self.linker)
131                .await?;
132        let result = instance
133            .n7n_fix_deps_detector()
134            .call_detect(&mut store)
135            .await?;
136        Ok(result.map(|violations| violations.into_iter().map(Violation::from).collect()))
137    }
138
139    /// Перетворює на [`DetectFn`] — та сама інʼєкція, яку
140    /// `PluginRegistry::new`/`replace_detector` приймають від in-process
141    /// бекенда сьогодні (`crates/harness/src/registry.rs`).
142    ///
143    /// Помилка САМОГО виклику (інстанціювання впало, трап тощо) мапиться в
144    /// `Err(String)` — той самий канал, яким `detector.wit` уже описує
145    /// "інфраструктурний збій самого виклику" (доккоментар
146    /// `wit/detector.wit`), а не мовчазний "чисто".
147    #[must_use]
148    pub fn into_detect_fn(self) -> DetectFn {
149        let this = Arc::new(self);
150        Arc::new(move || {
151            let this = Arc::clone(&this);
152            Box::pin(async move {
153                this.call()
154                    .await
155                    .unwrap_or_else(|err| Err(format!("wasm-компонент detector: {err}")))
156            })
157        })
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    //! Компіляційний доказ, що `From<bindings::...::Violation> for
164    //! Violation` — РЕАЛЬНИЙ конвертер `bindgen!`-типу (доккоментар
165    //! `From` вище) — узгоджений із `wit/detector.wit` без потреби в
166    //! `.wasm`-компоненті (доккоментар crate-level "Тестовий guest",
167    //! сценарій 2: "тести обмеж тим, що можливо без `.wasm`"). Наскрізний
168    //! доказ (реальний guest, реальний виклик) — `tests/wasmtime_backend.rs`.
169    use super::*;
170
171    #[test]
172    fn bindgen_violation_converts_to_harness_violation_field_by_field() {
173        let wit_violation = bindings::exports::n7n::fix_deps::detector::Violation {
174            file: "src/a.rs".to_string(),
175            line: Some(12),
176            message: "порушення".to_string(),
177        };
178
179        let violation = Violation::from(wit_violation);
180
181        assert_eq!(violation.file, PathBuf::from("src/a.rs"));
182        assert_eq!(violation.line, Some(12));
183        assert_eq!(violation.message, "порушення");
184    }
185
186    #[test]
187    fn bindgen_violation_without_a_line_converts_to_none() {
188        let wit_violation = bindings::exports::n7n::fix_deps::detector::Violation {
189            file: "src/b.rs".to_string(),
190            line: None,
191            message: "без рядка".to_string(),
192        };
193
194        assert_eq!(Violation::from(wit_violation).line, None);
195    }
196}