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}