Skip to main content

linsight_plugin_sdk/
plugin.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3
4use linsight_core::{Reading, SensorId};
5use stabby::result::Result as SResult;
6use stabby::string::String as SString;
7use thiserror::Error;
8
9use crate::manifest::{PluginManifest, RPluginManifest};
10use crate::mirror::{RReading, RSensorId};
11
12// ---------------------------------------------------------------------------
13// Host-facing error type.
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, Error, Clone)]
17pub enum PluginError {
18    #[error("io: {0}")]
19    Io(String),
20    #[error("parse: {0}")]
21    Parse(String),
22    #[error("unsupported sensor: {0}")]
23    Unsupported(String),
24    #[error("transient: {0}")]
25    Transient(String),
26    /// Host-side timeout: a blocking external call or sample did not finish
27    /// within its wall-clock deadline. There is no corresponding
28    /// `RPluginErrorKind::Timeout` because plugins cannot return this across
29    /// the FFI boundary; the daemon synthesizes it when a sample exceeds its
30    /// deadline.
31    #[error("timeout: {0}")]
32    Timeout(String),
33    /// ABI v4 host-side validation failure on a plugin's manifest. This
34    /// variant has no corresponding `RPluginError` discriminant — the
35    /// daemon synthesizes it during `host_init` after inspecting the
36    /// raw R-mirror manifest. Plugins cannot produce it across the FFI
37    /// boundary; cross-process error payloads from a plugin route
38    /// through `Io` / `Parse` / `Unsupported` / `Transient` instead.
39    #[error("manifest: {0}")]
40    Manifest(String),
41    /// Plugin code panicked during `init` or `sample`. Caught by
42    /// `catch_unwind` so the daemon stays alive.
43    #[error("plugin panicked: {0}")]
44    Panic(String),
45}
46
47// ---------------------------------------------------------------------------
48// R-mirror error type — what the stabbified trait returns across the FFI
49// boundary. The first field tags the variant; the second carries the
50// message.
51// ---------------------------------------------------------------------------
52
53#[stabby::stabby]
54#[repr(u8)]
55#[derive(Clone, Copy, Debug)]
56pub enum RPluginErrorKind {
57    Io,
58    Parse,
59    Unsupported,
60    Transient,
61}
62
63#[stabby::stabby]
64#[derive(Clone, Debug)]
65pub struct RPluginError {
66    pub kind: RPluginErrorKind,
67    pub message: SString,
68}
69
70impl From<PluginError> for RPluginError {
71    fn from(e: PluginError) -> Self {
72        let (kind, message) = match e {
73            PluginError::Io(s) => (RPluginErrorKind::Io, s),
74            PluginError::Parse(s) => (RPluginErrorKind::Parse, s),
75            PluginError::Unsupported(s) => (RPluginErrorKind::Unsupported, s),
76            PluginError::Transient(s) => (RPluginErrorKind::Transient, s),
77            // `Timeout` is host-side only — see the variant doc. Route it
78            // through `Transient` so a plugin that somehow produces it does
79            // not force the host to abort on an unknown discriminant.
80            PluginError::Timeout(s) => (RPluginErrorKind::Transient, format!("timeout: {s}")),
81            // `Manifest` is host-side only — see the variant doc. If a
82            // plugin somehow returns one (it shouldn't be able to: there
83            // is no `RPluginErrorKind::Manifest`), route it through `Io`
84            // with the message preserved, so the operator still sees
85            // the diagnostic text.
86            PluginError::Manifest(s) => (RPluginErrorKind::Io, format!("manifest: {s}")),
87            PluginError::Panic(s) => (RPluginErrorKind::Io, format!("plugin panicked: {s}")),
88        };
89        Self { kind, message: message.as_str().into() }
90    }
91}
92
93impl From<RPluginError> for PluginError {
94    fn from(r: RPluginError) -> Self {
95        let msg = r.message.as_str().to_owned();
96        match r.kind {
97            RPluginErrorKind::Io => PluginError::Io(msg),
98            RPluginErrorKind::Parse => PluginError::Parse(msg),
99            RPluginErrorKind::Unsupported => PluginError::Unsupported(msg),
100            RPluginErrorKind::Transient => PluginError::Transient(msg),
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// PluginCtx — read-only context passed to `init`. v2 carries an optional
107// sysroot override (used by test harnesses to point a plugin at synthetic
108// sysfs); future fields (logger handle, etc.) extend this struct.
109//
110// The sysroot crosses the FFI boundary as a `stabby::Option<SString>` so
111// `None` is encoded structurally rather than through a paired bool. The
112// path must be valid UTF-8: an OS path with non-UTF-8 bytes is rejected at
113// the `PluginCtx::new_with_sysroot` constructor with an explicit error
114// rather than being silently lossy. (Linux paths are arbitrary bytes, but
115// LinSight's in-tree test fixtures all use ASCII tempdir paths, so this
116// is a safe contract for plugin authors.)
117// ---------------------------------------------------------------------------
118
119#[derive(Clone, Debug, Default)]
120pub struct PluginCtx {
121    sysroot: Option<std::path::PathBuf>,
122    config: serde_json::Value,
123}
124
125#[derive(Debug, thiserror::Error)]
126pub enum PluginCtxError {
127    #[error("sysroot path is not valid UTF-8: {0:?}")]
128    NonUtf8Sysroot(std::path::PathBuf),
129}
130
131impl PluginCtx {
132    pub fn new_with_sysroot(path: std::path::PathBuf) -> Result<Self, PluginCtxError> {
133        if path.to_str().is_none() {
134            return Err(PluginCtxError::NonUtf8Sysroot(path));
135        }
136        Ok(Self { sysroot: Some(path), config: serde_json::Value::Null })
137    }
138
139    pub fn sysroot(&self) -> Option<&std::path::Path> {
140        self.sysroot.as_deref()
141    }
142
143    pub fn config(&self) -> &serde_json::Value {
144        &self.config
145    }
146
147    pub fn with_config(mut self, config: serde_json::Value) -> Self {
148        self.config = config;
149        self
150    }
151}
152
153// `PluginCtx::default()` (derived) and the removed `new()` returned the
154// same value (`sysroot: None`); keeping both was redundant. Callers
155// that want the empty context should write `PluginCtx::default()`.
156
157/// R-mirror context — the FFI form of [`PluginCtx`]. `sysroot` is
158/// carried as an `SString` paired with a `sysroot_set: u8` bool flag
159/// (kept from v2 — the v2→v3 ABI bump was driven by the stabby
160/// release-mode `match_owned` bug on RUnit/RReading/RCell, not by
161/// this struct). Migration to `stabby::Option<SString>` is tracked
162/// in the open-followups doc for the next ABI revision.
163#[stabby::stabby]
164#[derive(Clone, Debug, Default)]
165pub struct RPluginCtx {
166    pub sysroot: SString,
167    pub sysroot_set: u8,
168    pub config_json: SString,
169}
170
171impl From<&PluginCtx> for RPluginCtx {
172    fn from(ctx: &PluginCtx) -> Self {
173        let sysroot = match &ctx.sysroot {
174            Some(p) => {
175                let s: SString = p.to_str().expect("PluginCtx invariant: sysroot is UTF-8").into();
176                (s, 1u8)
177            }
178            None => (SString::default(), 0u8),
179        };
180        let config_json = match &ctx.config {
181            serde_json::Value::Null => SString::default(),
182            v => v.to_string().into(),
183        };
184        Self { sysroot: sysroot.0, sysroot_set: sysroot.1, config_json }
185    }
186}
187
188impl From<&RPluginCtx> for PluginCtx {
189    fn from(r: &RPluginCtx) -> Self {
190        let sysroot = if r.sysroot_set != 0 {
191            Some(std::path::PathBuf::from(r.sysroot.as_str()))
192        } else {
193            None
194        };
195        let config = if r.config_json.as_str().is_empty() {
196            serde_json::Value::Null
197        } else {
198            serde_json::from_str(r.config_json.as_str()).unwrap_or(serde_json::Value::Null)
199        };
200        Self { sysroot, config }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// The stabbified plugin trait.
206//
207// Every method uses `extern "C-unwind"` plus R-mirror types so the resulting
208// vtable is FFI-safe across rustc minor versions. `C-unwind` (rather than
209// plain `C`) lets a panic inside a plugin method unwind across the FFI
210// boundary so the host's `catch_unwind` (in `host_init`/`host_sample`) can
211// turn it into a `PluginError::Panic` instead of the plugin force-aborting
212// the whole daemon at the boundary. (Panic isolation also requires the
213// daemon to be built with `panic = "unwind"`; see the release profile.)
214// Plugins typically construct linsight-core values internally and call
215// `.into()` at the return.
216// ---------------------------------------------------------------------------
217
218pub type RInitResult = SResult<RPluginManifest, RPluginError>;
219pub type RSampleResult = SResult<RReading, RPluginError>;
220
221#[stabby::stabby]
222pub trait LinsightPlugin: Send + Sync {
223    extern "C-unwind" fn init(&self, ctx: &RPluginCtx) -> RInitResult;
224    extern "C-unwind" fn sample(&self, sensor: RSensorId) -> RSampleResult;
225    extern "C-unwind" fn shutdown(&self) {}
226}
227
228// ---------------------------------------------------------------------------
229// Host-side convenience: call the trait with host-side types and get
230// host-side types back. Plugin authors should not need this — they
231// implement the trait directly.
232// ---------------------------------------------------------------------------
233
234/// Convenience wrapper: call `plugin.init(&ctx)` with a host-side
235/// [`PluginCtx`] and get a host-side [`PluginManifest`] back.
236///
237/// Validates every sensor descriptor's ID via [`SensorId::try_new`] before
238/// returning. The `From<RSensorId> for SensorId` conversion uses the
239/// infallible `SensorId::new` (which only `debug_assert!`s on invariants);
240/// without this check, a release-mode plugin returning an empty or
241/// whitespace-bearing ID would silently corrupt the daemon's registry.
242#[must_use = "host_init returns Result<PluginManifest, PluginError>; ignoring the manifest discards the plugin's sensor catalogue"]
243pub fn host_init(
244    plugin: &dyn LinsightPlugin,
245    ctx: &PluginCtx,
246) -> Result<PluginManifest, PluginError> {
247    let rctx: RPluginCtx = ctx.into();
248    let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.init(&rctx)))
249        .map_err(|_| PluginError::Panic("plugin init panicked".into()))?;
250    let std_res: core::result::Result<RPluginManifest, RPluginError> = r.into();
251    let r_manifest = std_res.map_err(PluginError::from)?;
252    // Validate every sensor ID's raw FFI string BEFORE the From-conversion
253    // calls the infallible (debug_assert!-only) `SensorId::new`. Doing this
254    // on the std-typed manifest after conversion would already have hit
255    // `debug_assert!` in debug builds, but in release builds the bad id
256    // would have slipped silently into the registry. Walk the raw stabby
257    // strings and run them through `try_new`.
258    let plugin_id_for_err = r_manifest.plugin_id.as_str().to_owned();
259    for i in 0..r_manifest.sensors.len() {
260        // SVec doesn't expose `iter()` directly across all stabby
261        // versions; index access via slice works.
262        let raw = r_manifest.sensors.as_slice()[i].id.value.as_str();
263        SensorId::try_new(raw).map_err(|e| {
264            PluginError::Parse(format!(
265                "plugin `{plugin_id_for_err}` returned invalid sensor id `{raw}`: {e}",
266            ))
267        })?;
268    }
269    // ABI v4: enforce manifest invariants on `devices` + `device_key`
270    // BEFORE the From-conversion runs (the std-typed conversion calls
271    // `HardwareDeviceKey::try_new(...).expect(...)` and would panic on
272    // a malformed key — `validate_manifest` returns a structured error
273    // instead).
274    crate::manifest::validate_manifest(&r_manifest)?;
275    Ok(r_manifest.into())
276}
277
278/// Convenience wrapper: call `plugin.sample(id)` with a host-side
279/// [`SensorId`] and get a host-side [`Reading`] back.
280#[must_use = "host_sample returns the sampled Reading; ignoring it drops the value the plugin produced"]
281pub fn host_sample(plugin: &dyn LinsightPlugin, id: &SensorId) -> Result<Reading, PluginError> {
282    let rid: RSensorId = id.into();
283    let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.sample(rid)))
284        .map_err(|_| PluginError::Panic("plugin sample panicked".into()))?;
285    let std_res: core::result::Result<RReading, RPluginError> = r.into();
286    match std_res {
287        Ok(r) => Ok(r.into()),
288        Err(e) => Err(e.into()),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use linsight_core::SensorId;
295    use stabby::result::Result as SResult;
296
297    use super::*;
298    use crate::manifest::{PluginManifest, RPluginManifest};
299
300    struct NoopPlugin;
301
302    impl LinsightPlugin for NoopPlugin {
303        extern "C-unwind" fn init(&self, _ctx: &RPluginCtx) -> RInitResult {
304            let m = PluginManifest {
305                plugin_id: "test".into(),
306                display_name: "Test".into(),
307                version: "0.0.1".into(),
308                sensors: vec![],
309                devices: vec![],
310            };
311            let r: RPluginManifest = m.into();
312            SResult::Ok(r)
313        }
314
315        extern "C-unwind" fn sample(&self, _sensor: RSensorId) -> RSampleResult {
316            let e: RPluginError = PluginError::Unsupported("no sensors".into()).into();
317            SResult::Err(e)
318        }
319    }
320
321    #[test]
322    fn noop_plugin_init_runs() {
323        let p = NoopPlugin;
324        let m = host_init(&p, &PluginCtx::default()).unwrap();
325        assert_eq!(m.plugin_id, "test");
326    }
327
328    #[test]
329    fn noop_plugin_sample_errors() {
330        let p = NoopPlugin;
331        let err = host_sample(&p, &SensorId::new("foo")).unwrap_err();
332        assert!(matches!(err, PluginError::Unsupported(_)));
333    }
334
335    /// Plugin that returns a manifest with an invalid sensor ID (interior
336    /// whitespace). Used to exercise `host_init`'s validation pass.
337    /// Without that pass, the bad ID would slip through the FFI mirror
338    /// because `From<RSensorId> for SensorId` calls `SensorId::new`,
339    /// which is `debug_assert!`-only in release builds.
340    struct BadIdPlugin;
341
342    impl LinsightPlugin for BadIdPlugin {
343        extern "C-unwind" fn init(&self, _ctx: &RPluginCtx) -> RInitResult {
344            // We deliberately bypass `SensorId::try_new` here to construct
345            // a value that violates the invariant — this is what a
346            // misbehaving plugin compiled in release mode could produce
347            // through the FFI mirror.
348            let bad: SString = "bad id".into(); // contains whitespace
349            let r_desc = crate::manifest::RSensorDescriptor {
350                id: RSensorId { value: bad },
351                display_name: "Bad".into(),
352                unit: crate::mirror::RUnit {
353                    kind: crate::mirror::RUnitKind::Count,
354                    custom: stabby::option::Option::None(),
355                },
356                kind: crate::mirror::RSensorKind::Scalar,
357                category: crate::mirror::RCategory::Custom,
358                native_rate_hz: 1.0,
359                min: stabby::option::Option::None(),
360                max: stabby::option::Option::None(),
361                device_id: stabby::option::Option::None(),
362                device_key: stabby::option::Option::None(),
363                tags: stabby::vec::Vec::new(),
364            };
365            let mut sensors = stabby::vec::Vec::new();
366            sensors.push(r_desc);
367            let r = RPluginManifest {
368                plugin_id: "test.bad".into(),
369                display_name: "Bad".into(),
370                version: "0.0.1".into(),
371                sensors,
372                devices: stabby::vec::Vec::new(),
373            };
374            SResult::Ok(r)
375        }
376
377        extern "C-unwind" fn sample(&self, _: RSensorId) -> RSampleResult {
378            let e: RPluginError = PluginError::Unsupported("no".into()).into();
379            SResult::Err(e)
380        }
381    }
382
383    #[test]
384    fn host_init_rejects_plugin_with_invalid_sensor_id() {
385        // Regression guard for the FFI validation gap: a plugin returning
386        // a manifest with a whitespace-bearing sensor ID must be rejected
387        // by `host_init` (not silently accepted into the registry as it
388        // would be by the debug_assert-only `SensorId::new`).
389        let p = BadIdPlugin;
390        let err = host_init(&p, &PluginCtx::default()).unwrap_err();
391        match err {
392            PluginError::Parse(msg) => {
393                assert!(
394                    msg.contains("bad id") || msg.contains("whitespace"),
395                    "expected error to name the bad id or invariant; got: {msg}",
396                );
397            }
398            other => panic!("expected PluginError::Parse, got {other:?}"),
399        }
400    }
401
402    /// Plugin whose `init`/`sample` panic. With the `extern "C-unwind"`
403    /// trait ABI, the panic unwinds across the boundary and `host_init` /
404    /// `host_sample` catch it into `PluginError::Panic`. Under the old
405    /// `extern "C"` ABI this would force-abort the process instead — so
406    /// this is the regression guard for M3 (plugin panic isolation).
407    struct PanicPlugin;
408
409    impl LinsightPlugin for PanicPlugin {
410        extern "C-unwind" fn init(&self, _ctx: &RPluginCtx) -> RInitResult {
411            panic!("boom in init");
412        }
413
414        extern "C-unwind" fn sample(&self, _: RSensorId) -> RSampleResult {
415            panic!("boom in sample");
416        }
417    }
418
419    #[test]
420    fn host_init_catches_plugin_panic() {
421        let err = host_init(&PanicPlugin, &PluginCtx::default()).unwrap_err();
422        assert!(matches!(err, PluginError::Panic(_)), "expected Panic, got {err:?}");
423    }
424
425    #[test]
426    fn host_sample_catches_plugin_panic() {
427        let err = host_sample(&PanicPlugin, &SensorId::new("x")).unwrap_err();
428        assert!(matches!(err, PluginError::Panic(_)), "expected Panic, got {err:?}");
429    }
430
431    #[test]
432    fn plugin_ctx_rejects_non_utf8_sysroot() {
433        // OsString built from raw bytes that are not valid UTF-8. On
434        // Linux, PathBuf accepts these. The constructor must refuse them
435        // so the FFI mirror's UTF-8 contract holds.
436        use std::os::unix::ffi::OsStringExt;
437        let bytes: Vec<u8> = vec![b'/', 0xff, 0xfe, b'/', b'x'];
438        let bad = std::path::PathBuf::from(std::ffi::OsString::from_vec(bytes));
439        let err = PluginCtx::new_with_sysroot(bad).unwrap_err();
440        assert!(matches!(err, PluginCtxError::NonUtf8Sysroot(_)));
441    }
442}