Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
437 changes: 403 additions & 34 deletions crates/rules-napi/src/lib.rs

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions crates/rules-plugin-host/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,36 @@ pub enum PluginHostError {
source: anyhow::Error,
},

/// Корінь, від якого резолвляться `capabilities.fs_read`-preopens, —
/// відносний шлях. Відносний корінь дорезолвився б від
/// `std::env::current_dir()` ХОСТ-ПРОЦЕСУ, тобто рівно та підміна
/// дерева, яку [`crate::PluginHost::load_in_root`] і закриває (§2.95
/// реєстру відкритих питань): для `lint --path <інше-дерево>` гість
/// читав би не те дерево, що лінтується. Тому — типізована відмова, а
/// не «якось зарезолвимо».
#[error(
"корінь preopen-ів `{root}` має бути АБСОЛЮТНИМ: відносний резолвився б від cwd \
хост-процесу, а не від кореня дерева, що лінтується"
)]
RelativePreopenRoot { root: PathBuf },

/// Плагін заявив непорожній `capabilities.fs_read`, але завантажений
/// БЕЗ кореня ([`crate::PluginHost::load`], не `load_in_root`) — жодного
/// preopen не відкрито. Виклик гостя в такому стані означав би, що гість
/// мовчки не бачить нічого зі заявленого; замість тихої деградації —
/// типізована помилка в точці шкоди (`detect`/`fix`), тоді як
/// `describe()` на такому плагіні лишається робочим.
#[error(
"плагін `{plugin_id}` заявляє capabilities.fs-read {paths:?}, але завантажений без \
кореня preopen-ів — виклик `{function}` дав би гостю порожню пісочницю; \
вантажте його через `PluginHost::load_in_root(.., <корінь дерева>)`"
)]
FsReadRootUnbound {
plugin_id: String,
paths: Vec<String>,
function: &'static str,
},

/// Guest повернув дані, які не конвертуються в DTO контракту (напр.
/// невалідний JSON у `diagnostic.data`).
#[error("плагін повернув дані, несумісні з контрактом: {0}")]
Expand Down
95 changes: 83 additions & 12 deletions crates/rules-plugin-host/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,55 @@ impl PluginHost {
&self,
path: &Path,
expected_world_version: &str,
) -> Result<LoadedPlugin, PluginHostError> {
self.load_impl(path, expected_world_version, None)
}

/// Те саме, що [`Self::load`], але з ЯВНИМ коренем, від якого
/// резолвляться `capabilities.fs_read`-preopens (§2.95 реєстру
/// відкритих питань).
///
/// # Навіщо окремий вхід, а не `current_dir()`
///
/// До цієї правки preopens резолвились від `std::env::current_dir()`
/// ХОСТ-ПРОЦЕСУ, тоді як корінь дерева, що лінтується, приходить окремим
/// параметром (`cwd` у `run_wasm_concern`/`run_wasm_concern_fix`
/// `crates/rules-napi`). Для `lint --path <інше-дерево>` ці два корені
/// розходяться, і гість читав би не те дерево — мовчки, бо назви
/// файлів у чужому дереві ті самі. Чинних споживачів `fs_read` немає
/// (усі маніфести лишають його порожнім), тож дефект був латентним:
/// його треба було зняти ДО першого споживача, інакше він проявився б
/// як тиха підміна кореня.
///
/// `root` МУСИТЬ бути абсолютним ([`PluginHostError::RelativePreopenRoot`]):
/// відносний дорезолвився б `Path::join`-ом від cwd процесу — тобто
/// повернув би рівно ту саму ваду з іншого боку.
pub fn load_in_root(
&self,
path: &Path,
expected_world_version: &str,
root: &Path,
) -> Result<LoadedPlugin, PluginHostError> {
if !root.is_absolute() {
return Err(PluginHostError::RelativePreopenRoot {
root: root.to_path_buf(),
});
}
self.load_impl(path, expected_world_version, Some(root))
}

/// Спільне тіло [`Self::load`]/[`Self::load_in_root`] — `preopen_root:
/// None` означає «корінь не заявлений»: preopens НЕ відкриваються
/// взагалі (жодного мовчазного fallback-у на cwd процесу), а плагін із
/// непорожнім `fs_read` лишається придатним лише до `describe()` —
/// перший же `detect`/`fix` падає
/// [`PluginHostError::FsReadRootUnbound`] (доккомент
/// `LoadedPlugin::ensure_fs_read_bound`).
fn load_impl(
&self,
path: &Path,
expected_world_version: &str,
preopen_root: Option<&Path>,
) -> Result<LoadedPlugin, PluginHostError> {
let bytes = std::fs::read(path).map_err(|err| PluginHostError::Load {
path: path.to_path_buf(),
Expand All @@ -105,8 +154,10 @@ impl PluginHost {
source: err.into(),
})?;

// Probe — завжди `Capabilities::default()` (порожній `fs_read`), тож
// корінь preopen-ів цій фазі не потрібен: `None`.
let probe_manifest =
self.describe_with_capabilities(&component, &Capabilities::default())?;
self.describe_with_capabilities(&component, &Capabilities::default(), None)?;
check_world_version(&probe_manifest.world_version, expected_world_version)?;
// Маніфест — недовірений вхід рівно так само, як fix-план
// (`LoadedPlugin::fix` → `validators::fix`). Мажор `4.0.0` (§2.84)
Expand All @@ -132,7 +183,7 @@ impl PluginHost {
// `Store` реально містить запис про перший виклик (доккомент
// `Self::load` вище: «перший виклик хоста після завантаження
// компонента»), а не запис у відкинутому probe-`Store`.
let host_state = self.build_host_state(&probe_manifest.capabilities)?;
let host_state = self.build_host_state(&probe_manifest.capabilities, preopen_root)?;
let mut store = Store::new(&self.engine, host_state);
let plugin = wit::Plugin::instantiate(&mut store, &component, &self.linker)
.map_err(|err| PluginHostError::Instantiate(err.into()))?;
Expand All @@ -145,7 +196,12 @@ impl PluginHost {
})?;
let manifest = convert::manifest_from_wit(manifest);

Ok(LoadedPlugin::new(store, plugin, manifest))
Ok(LoadedPlugin::new(
store,
plugin,
manifest,
preopen_root.map(Path::to_path_buf),
))
}

/// Фаза 1 інстанціації — див. доккомент [`Self::load`]: інстанціює
Expand All @@ -156,8 +212,9 @@ impl PluginHost {
&self,
component: &Component,
capabilities: &Capabilities,
preopen_root: Option<&Path>,
) -> Result<Manifest, PluginHostError> {
let host_state = self.build_host_state(capabilities)?;
let host_state = self.build_host_state(capabilities, preopen_root)?;
let mut store = Store::new(&self.engine, host_state);
let plugin = wit::Plugin::instantiate(&mut store, component, &self.linker)
.map_err(|err| PluginHostError::Instantiate(err.into()))?;
Expand All @@ -173,18 +230,32 @@ impl PluginHost {

/// Будує `HostState` (WASI-контекст + наш host-стан) за
/// `capabilities`: `fs_read`-шляхи — read-only preopens, резолвлені
/// відносно поточної робочої директорії хоста (та сама конвенція, що й
/// `SourceFile::path` — «posix-relative шлях від cwd»). Глоб-патерни в
/// `fs_read` НЕ розкриваються тут (v3.0-обмеження, задокументовано —
/// відносно `preopen_root` — КОРЕНЯ ДЕРЕВА, ЩО ЛІНТУЄТЬСЯ (та сама
/// конвенція, що й `SourceFile::path` — «posix-relative шлях від
/// `cwd`-параметра виклику»), а НЕ від `std::env::current_dir()`
/// хост-процесу (§2.95, доккомент [`Self::load_in_root`]). Глоб-патерни
/// в `fs_read` НЕ розкриваються тут (v3.0-обмеження, задокументовано —
/// типовий концерн лишає `fs_read` порожнім, вміст файлів хост передає
/// inline у `DetectBatch`/`FixRequest`; глоб-резолвинг — кандидат на
/// майбутню оркестрацію, фаза 7).
fn build_host_state(&self, capabilities: &Capabilities) -> Result<HostState, PluginHostError> {
///
/// `preopen_root: None` (завантаження без кореня — [`Self::load`]) НЕ
/// відкриває жодного preopen-у навіть за непорожнього `fs_read`:
/// мовчазний fallback на cwd процесу і був вадою. Гучність цього стану
/// забезпечує `LoadedPlugin` на першому ж `detect`/`fix`.
fn build_host_state(
&self,
capabilities: &Capabilities,
preopen_root: Option<&Path>,
) -> Result<HostState, PluginHostError> {
let mut builder = WasiCtxBuilder::new();
let cwd = std::env::current_dir()
.map_err(|err| PluginHostError::Instantiate(anyhow::Error::new(err)))?;
for rel in &capabilities.fs_read {
let host_path = cwd.join(rel);
for rel in preopen_root
.into_iter()
.flat_map(|_| capabilities.fs_read.iter())
{
let host_path = preopen_root
.expect("гілка ітерується лише коли корінь заявлений")
.join(rel);
builder
// `FsPerms::ReadOnly` — wasmtime-wasi 48 звів колишню пару
// `DirPerms`/`FilePerms` в один enum (обидва прапорці все одно
Expand Down
47 changes: 46 additions & 1 deletion crates/rules-plugin-host/src/loaded_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! інстансів: найпростіший reuse, без крос-плагінного пулу — див.
//! доккомент `PluginHost::load`).

use std::path::{Path, PathBuf};
use std::sync::Arc;

use wasmtime::Store;
Expand All @@ -25,17 +26,59 @@ pub struct LoadedPlugin {
store: Store<HostState>,
plugin: wit::Plugin,
manifest: Manifest,
/// Корінь, від якого відкриті `capabilities.fs_read`-preopens цього
/// `Store` (`PluginHost::load_in_root`), або `None` — плагін
/// завантажений без кореня (`PluginHost::load`). Поле потрібне не лише
/// для гейта [`Self::ensure_fs_read_bound`]: `Store` створюється раз, а
/// корінь дерева приходить із КОЖНИМ napi-викликом окремо, тож
/// кешувальний шар (`crates/rules-napi`) звіряє цим акцесором, чи
/// закешований інстанс відкритий саме на потрібне дерево, і
/// перезавантажує плагін, коли ні (§2.95).
preopen_root: Option<PathBuf>,
}

impl LoadedPlugin {
pub(crate) fn new(store: Store<HostState>, plugin: wit::Plugin, manifest: Manifest) -> Self {
pub(crate) fn new(
store: Store<HostState>,
plugin: wit::Plugin,
manifest: Manifest,
preopen_root: Option<PathBuf>,
) -> Self {
Self {
store,
plugin,
manifest,
preopen_root,
}
}

/// Корінь, від якого відкриті preopens цього інстансу (доккомент поля
/// [`Self::preopen_root`]) — `None` для завантаження без кореня.
pub fn preopen_root(&self) -> Option<&Path> {
self.preopen_root.as_deref()
}

/// Гейт «заявлений `fs-read` без кореня» (§2.95): плагін із непорожнім
/// `capabilities.fs_read`, завантажений через `PluginHost::load` (без
/// кореня), не має ЖОДНОГО preopen-у — гість побачив би порожню
/// пісочницю й не відрізнив би її від «у дереві нічого немає».
/// Мовчазна деградація тут особливо підступна, бо саме так виглядав би
/// і старий дефект (preopen від cwd ХОСТ-ПРОЦЕСУ на чужому дереві:
/// шляхи резолвляться, вміст — не той), тож замість «якось працює»
/// виклик падає типізовано — у точці шкоди, а не при завантаженні:
/// `describe()` (маніфест, ensure-tool контур) кореня не потребує й
/// лишається робочим.
fn ensure_fs_read_bound(&self, function: &'static str) -> Result<(), PluginHostError> {
if self.manifest.capabilities.fs_read.is_empty() || self.preopen_root.is_some() {
return Ok(());
}
Err(PluginHostError::FsReadRootUnbound {
plugin_id: self.manifest.id.clone(),
paths: self.manifest.capabilities.fs_read.clone(),
function,
})
}

/// Маніфест плагіна, отриманий `describe()` один раз у
/// `PluginHost::load` (спека §3.2: «перший виклик хоста після
/// завантаження компонента») і закешований — метод сам guest не кличе.
Expand Down Expand Up @@ -71,6 +114,7 @@ impl LoadedPlugin {
/// концерну. Той самий `Store`/`Instance`, що й попередні виклики цього
/// `LoadedPlugin` (reuse).
pub fn detect(&mut self, batch: &DetectBatch) -> Result<Vec<Diagnostic>, PluginHostError> {
self.ensure_fs_read_bound("detect")?;
let wit_batch = convert::detect_batch_to_wit(batch);
self.reset_scratch();
let result = self.plugin.call_detect(&mut self.store, &wit_batch);
Expand Down Expand Up @@ -109,6 +153,7 @@ impl LoadedPlugin {
/// [`PluginHostError::InvalidContractData`] (не часткове застосування) —
/// той самий контракт, що конверсія `diagnostic.data` у `detect`.
pub fn fix(&mut self, request: &FixRequest) -> Result<FixPlan, PluginHostError> {
self.ensure_fs_read_bound("fix")?;
let wit_request = convert::fix_request_to_wit(request);
self.reset_scratch();
let plan = self.plugin.call_fix(&mut self.store, &wit_request);
Expand Down
Loading
Loading