Skip to main content

nautilus_plugin/
manifest.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Static plug-in metadata returned from `nautilus_plugin_init`.
17
18use std::{fmt::Display, slice};
19
20use nautilus_model::types::fixed::FIXED_PRECISION;
21
22use crate::{
23    NAUTILUS_PLUGIN_ABI_VERSION, PLUGIN_BUILD_ID_VERSION, boundary::BorrowedStr, host::HostVTable,
24};
25
26/// Signature of the single `extern "C"` entry symbol every plug-in exports
27/// under the name [`crate::NAUTILUS_PLUGIN_INIT_SYMBOL`].
28pub type PluginInitFn = unsafe extern "C" fn(host: *const HostVTable) -> *const PluginManifest;
29
30/// Versioned build identifier carried by [`PluginManifest`].
31///
32/// The fields identify the Nautilus plug-in crate and build environment that
33/// produced the manifest. The host validates the precision mode because it
34/// changes model type layout across the plug-in boundary. Other build fields
35/// remain diagnostic.
36#[repr(C)]
37#[derive(Debug, Clone, Copy)]
38pub struct PluginBuildId {
39    /// Build identifier schema version. Must equal
40    /// [`PLUGIN_BUILD_ID_VERSION`] for the fields below.
41    pub schema_version: u32,
42
43    /// Version of the `nautilus-plugin` crate used to build the plug-in.
44    pub nautilus_plugin_version: BorrowedStr<'static>,
45
46    /// Rust compiler version reported by `rustc --version`, or empty when it
47    /// was unavailable to the build script.
48    pub rustc_version: BorrowedStr<'static>,
49
50    /// Cargo target triple, or empty when Cargo did not expose one.
51    pub target_triple: BorrowedStr<'static>,
52
53    /// Cargo build profile, or empty when Cargo did not expose one.
54    pub build_profile: BorrowedStr<'static>,
55
56    /// Model fixed-point precision mode used to build the plug-in.
57    pub precision_mode: BorrowedStr<'static>,
58
59    /// Maximum fixed-point decimal precision used to build the plug-in.
60    pub fixed_precision: u8,
61}
62
63impl PluginBuildId {
64    /// Returns the build identifier for the compiled `nautilus-plugin` crate.
65    #[must_use]
66    pub const fn current() -> Self {
67        Self {
68            schema_version: PLUGIN_BUILD_ID_VERSION,
69            nautilus_plugin_version: BorrowedStr::from_str(env!("CARGO_PKG_VERSION")),
70            rustc_version: BorrowedStr::from_str(env!("NAUTILUS_PLUGIN_BUILD_RUSTC_VERSION")),
71            target_triple: BorrowedStr::from_str(env!("NAUTILUS_PLUGIN_BUILD_TARGET")),
72            build_profile: BorrowedStr::from_str(env!("NAUTILUS_PLUGIN_BUILD_PROFILE")),
73            precision_mode: BorrowedStr::from_str(compiled_precision_mode()),
74            fixed_precision: FIXED_PRECISION,
75        }
76    }
77}
78
79/// Returns the model precision mode compiled into this crate.
80#[must_use]
81pub const fn compiled_precision_mode() -> &'static str {
82    if FIXED_PRECISION > 9 {
83        "high-precision"
84    } else {
85        "standard"
86    }
87}
88
89/// Manifest validation failures collected by the host loader.
90#[derive(Clone, Debug, Default, PartialEq, Eq)]
91pub struct PluginManifestValidationErrors {
92    messages: Vec<String>,
93}
94
95impl PluginManifestValidationErrors {
96    /// Returns whether validation found no failures.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.messages.is_empty()
100    }
101
102    /// Returns the validation failure messages in deterministic order.
103    #[must_use]
104    pub fn messages(&self) -> &[String] {
105        &self.messages
106    }
107
108    fn push(&mut self, message: impl Into<String>) {
109        self.messages.push(message.into());
110    }
111}
112
113impl Display for PluginManifestValidationErrors {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        for (index, message) in self.messages.iter().enumerate() {
116            if index > 0 {
117                write!(f, "; ")?;
118            }
119            write!(f, "{message}")?;
120        }
121        Ok(())
122    }
123}
124
125impl std::error::Error for PluginManifestValidationErrors {}
126
127/// The static, process-lifetime metadata a plug-in returns from
128/// `nautilus_plugin_init`.
129///
130/// Public OSS metadata stops here. Strategy, actor, controller, and model
131/// extension registration belongs to the host/sys layer that generates and
132/// validates the private bridge contract.
133#[repr(C)]
134#[derive(Debug)]
135pub struct PluginManifest {
136    /// ABI version. Must equal [`NAUTILUS_PLUGIN_ABI_VERSION`] or the host
137    /// refuses to load the plug-in.
138    pub abi_version: u32,
139
140    /// Short machine-readable plug-in name (e.g. `"my-momentum"`).
141    pub plugin_name: BorrowedStr<'static>,
142
143    /// Free-form vendor or author string.
144    pub plugin_vendor: BorrowedStr<'static>,
145
146    /// Plug-in version (typically the crate's `CARGO_PKG_VERSION`).
147    pub plugin_version: BorrowedStr<'static>,
148
149    /// Versioned build identifier for diagnostics.
150    pub build_id: PluginBuildId,
151}
152
153impl PluginManifest {
154    /// Returns whether this manifest is compatible with the compiled-in ABI.
155    #[must_use]
156    pub fn matches_compiled_abi(&self) -> bool {
157        self.abi_version == NAUTILUS_PLUGIN_ABI_VERSION
158    }
159
160    /// Validates manifest invariants the host relies on before registration.
161    ///
162    /// # Errors
163    ///
164    /// Returns every structural problem found in the manifest.
165    pub fn validate(&self) -> Result<(), PluginManifestValidationErrors> {
166        let mut errors = PluginManifestValidationErrors::default();
167
168        if self.abi_version != NAUTILUS_PLUGIN_ABI_VERSION {
169            errors.push(format!(
170                "abi_version {} does not match supported ABI {}",
171                self.abi_version, NAUTILUS_PLUGIN_ABI_VERSION
172            ));
173        }
174        validate_required_str("plugin_name", self.plugin_name, &mut errors);
175        validate_optional_str("plugin_vendor", self.plugin_vendor, &mut errors);
176        validate_required_str("plugin_version", self.plugin_version, &mut errors);
177        validate_build_id(&self.build_id, &mut errors);
178
179        if errors.is_empty() {
180            Ok(())
181        } else {
182            Err(errors)
183        }
184    }
185}
186
187fn validate_build_id(build_id: &PluginBuildId, errors: &mut PluginManifestValidationErrors) {
188    if build_id.schema_version != PLUGIN_BUILD_ID_VERSION {
189        errors.push(format!(
190            "build_id.schema_version {} does not match supported schema {}",
191            build_id.schema_version, PLUGIN_BUILD_ID_VERSION
192        ));
193        return;
194    }
195
196    validate_optional_str(
197        "build_id.nautilus_plugin_version",
198        build_id.nautilus_plugin_version,
199        errors,
200    );
201    validate_optional_str("build_id.rustc_version", build_id.rustc_version, errors);
202    validate_optional_str("build_id.target_triple", build_id.target_triple, errors);
203    validate_optional_str("build_id.build_profile", build_id.build_profile, errors);
204    if let Some(precision_mode) =
205        validate_required_str("build_id.precision_mode", build_id.precision_mode, errors)
206    {
207        let expected = compiled_precision_mode();
208        if precision_mode != expected {
209            errors.push(format!(
210                "build_id.precision_mode '{precision_mode}' does not match host precision mode '{expected}'"
211            ));
212        }
213    }
214
215    if build_id.fixed_precision != FIXED_PRECISION {
216        errors.push(format!(
217            "build_id.fixed_precision {} does not match host fixed precision {}",
218            build_id.fixed_precision, FIXED_PRECISION
219        ));
220    }
221}
222
223fn validate_required_str<'a>(
224    field: &str,
225    value: BorrowedStr<'a>,
226    errors: &mut PluginManifestValidationErrors,
227) -> Option<&'a str> {
228    let text = validate_optional_str(field, value, errors)?;
229    if text.is_empty() {
230        errors.push(format!("{field} must not be empty"));
231    }
232    Some(text)
233}
234
235fn validate_optional_str<'a>(
236    field: &str,
237    value: BorrowedStr<'a>,
238    errors: &mut PluginManifestValidationErrors,
239) -> Option<&'a str> {
240    if value.len == 0 {
241        return Some("");
242    }
243
244    if value.ptr.is_null() {
245        errors.push(format!(
246            "{field} has null pointer with non-zero length {}",
247            value.len
248        ));
249        return None;
250    }
251
252    // SAFETY: validation only reads the descriptor supplied by the manifest producer.
253    let bytes = unsafe { slice::from_raw_parts(value.ptr, value.len) };
254    match std::str::from_utf8(bytes) {
255        Ok(text) => Some(text),
256        Err(e) => {
257            errors.push(format!("{field} is not valid UTF-8: {e}"));
258            None
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use std::ptr;
266
267    use rstest::rstest;
268
269    use super::*;
270
271    fn valid_manifest() -> PluginManifest {
272        PluginManifest {
273            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
274            plugin_name: BorrowedStr::from_str("test-plugin"),
275            plugin_vendor: BorrowedStr::from_str("nautech"),
276            plugin_version: BorrowedStr::from_str("1.0.0"),
277            build_id: PluginBuildId::current(),
278        }
279    }
280
281    #[rstest]
282    fn matches_compiled_abi_accepts_compiled_version() {
283        assert!(valid_manifest().matches_compiled_abi());
284    }
285
286    #[rstest]
287    fn matches_compiled_abi_rejects_mismatch() {
288        let manifest = PluginManifest {
289            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
290            ..valid_manifest()
291        };
292
293        assert!(!manifest.matches_compiled_abi());
294    }
295
296    #[rstest]
297    fn validate_accepts_valid_manifest() {
298        valid_manifest().validate().unwrap();
299    }
300
301    #[rstest]
302    fn validate_rejects_mismatched_abi() {
303        let manifest = PluginManifest {
304            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
305            ..valid_manifest()
306        };
307
308        let errors = manifest.validate().unwrap_err();
309        assert_eq!(
310            errors.messages(),
311            &[format!(
312                "abi_version {} does not match supported ABI {}",
313                NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
314                NAUTILUS_PLUGIN_ABI_VERSION
315            )]
316        );
317    }
318
319    #[rstest]
320    fn validate_rejects_missing_name() {
321        let manifest = PluginManifest {
322            plugin_name: BorrowedStr::empty(),
323            ..valid_manifest()
324        };
325
326        let errors = manifest.validate().unwrap_err();
327        assert_eq!(errors.messages(), &["plugin_name must not be empty"]);
328    }
329
330    #[rstest]
331    fn validate_rejects_mismatched_build_schema() {
332        let manifest = PluginManifest {
333            build_id: PluginBuildId {
334                schema_version: PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
335                ..PluginBuildId::current()
336            },
337            ..valid_manifest()
338        };
339
340        let errors = manifest.validate().unwrap_err();
341        assert_eq!(
342            errors.messages(),
343            &[format!(
344                "build_id.schema_version {} does not match supported schema {}",
345                PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
346                PLUGIN_BUILD_ID_VERSION
347            )]
348        );
349    }
350
351    #[rstest]
352    fn validate_rejects_null_pointer_with_nonzero_length() {
353        let mut plugin_name = BorrowedStr::from_str("x");
354        plugin_name.ptr = ptr::null();
355        plugin_name.len = 1;
356        let manifest = PluginManifest {
357            plugin_name,
358            ..valid_manifest()
359        };
360
361        let errors = manifest.validate().unwrap_err();
362        assert_eq!(
363            errors.messages(),
364            &["plugin_name has null pointer with non-zero length 1"]
365        );
366    }
367
368    #[rstest]
369    fn validate_rejects_invalid_utf8() {
370        static INVALID_UTF8: [u8; 1] = [0xFF];
371        let mut plugin_name = BorrowedStr::empty();
372        plugin_name.ptr = INVALID_UTF8.as_ptr();
373        plugin_name.len = INVALID_UTF8.len();
374        let manifest = PluginManifest {
375            plugin_name,
376            ..valid_manifest()
377        };
378
379        let errors = manifest.validate().unwrap_err();
380        assert!(errors.messages()[0].starts_with("plugin_name is not valid UTF-8:"));
381    }
382
383    #[rstest]
384    fn validate_rejects_mismatched_precision_mode() {
385        let wrong_precision_mode = match compiled_precision_mode() {
386            "standard" => "high-precision",
387            _ => "standard",
388        };
389        let manifest = PluginManifest {
390            build_id: PluginBuildId {
391                precision_mode: BorrowedStr::from_str(wrong_precision_mode),
392                ..PluginBuildId::current()
393            },
394            ..valid_manifest()
395        };
396
397        let errors = manifest.validate().unwrap_err();
398        assert_eq!(
399            errors.messages(),
400            &[format!(
401                "build_id.precision_mode '{wrong_precision_mode}' does not match host precision mode '{}'",
402                compiled_precision_mode()
403            )]
404        );
405    }
406
407    #[rstest]
408    fn validate_rejects_mismatched_fixed_precision() {
409        let manifest = PluginManifest {
410            build_id: PluginBuildId {
411                fixed_precision: FIXED_PRECISION.wrapping_add(1),
412                ..PluginBuildId::current()
413            },
414            ..valid_manifest()
415        };
416
417        let errors = manifest.validate().unwrap_err();
418        assert_eq!(
419            errors.messages(),
420            &[format!(
421                "build_id.fixed_precision {} does not match host fixed precision {}",
422                FIXED_PRECISION.wrapping_add(1),
423                FIXED_PRECISION
424            )]
425        );
426    }
427
428    #[rstest]
429    fn validation_errors_display_joins_messages() {
430        let mut errors = PluginManifestValidationErrors::default();
431        assert!(errors.is_empty());
432        errors.push("first");
433        errors.push("second");
434
435        assert!(!errors.is_empty());
436        assert_eq!(errors.to_string(), "first; second");
437    }
438
439    #[rstest]
440    fn current_build_id_matches_compiled_precision() {
441        let build_id = PluginBuildId::current();
442
443        assert_eq!(build_id.schema_version, PLUGIN_BUILD_ID_VERSION);
444        assert_eq!(build_id.fixed_precision, FIXED_PRECISION);
445        // SAFETY: build ID strings are process-lifetime static strings.
446        assert_eq!(
447            unsafe { build_id.precision_mode.as_str() },
448            compiled_precision_mode()
449        );
450    }
451}