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 an error if the manifest has structural problems. The error contains every problem
165    /// found.
166    pub fn validate(&self) -> Result<(), PluginManifestValidationErrors> {
167        let mut errors = PluginManifestValidationErrors::default();
168
169        if self.abi_version != NAUTILUS_PLUGIN_ABI_VERSION {
170            errors.push(format!(
171                "abi_version {} does not match supported ABI {}",
172                self.abi_version, NAUTILUS_PLUGIN_ABI_VERSION
173            ));
174        }
175        validate_required_str("plugin_name", self.plugin_name, &mut errors);
176        validate_optional_str("plugin_vendor", self.plugin_vendor, &mut errors);
177        validate_required_str("plugin_version", self.plugin_version, &mut errors);
178        validate_build_id(&self.build_id, &mut errors);
179
180        if errors.is_empty() {
181            Ok(())
182        } else {
183            Err(errors)
184        }
185    }
186}
187
188fn validate_build_id(build_id: &PluginBuildId, errors: &mut PluginManifestValidationErrors) {
189    if build_id.schema_version != PLUGIN_BUILD_ID_VERSION {
190        errors.push(format!(
191            "build_id.schema_version {} does not match supported schema {}",
192            build_id.schema_version, PLUGIN_BUILD_ID_VERSION
193        ));
194        return;
195    }
196
197    validate_optional_str(
198        "build_id.nautilus_plugin_version",
199        build_id.nautilus_plugin_version,
200        errors,
201    );
202    validate_optional_str("build_id.rustc_version", build_id.rustc_version, errors);
203    validate_optional_str("build_id.target_triple", build_id.target_triple, errors);
204    validate_optional_str("build_id.build_profile", build_id.build_profile, errors);
205    if let Some(precision_mode) =
206        validate_required_str("build_id.precision_mode", build_id.precision_mode, errors)
207    {
208        let expected = compiled_precision_mode();
209        if precision_mode != expected {
210            errors.push(format!(
211                "build_id.precision_mode '{precision_mode}' does not match host precision mode '{expected}'"
212            ));
213        }
214    }
215
216    if build_id.fixed_precision != FIXED_PRECISION {
217        errors.push(format!(
218            "build_id.fixed_precision {} does not match host fixed precision {}",
219            build_id.fixed_precision, FIXED_PRECISION
220        ));
221    }
222}
223
224fn validate_required_str<'a>(
225    field: &str,
226    value: BorrowedStr<'a>,
227    errors: &mut PluginManifestValidationErrors,
228) -> Option<&'a str> {
229    let text = validate_optional_str(field, value, errors)?;
230    if text.is_empty() {
231        errors.push(format!("{field} must not be empty"));
232    }
233    Some(text)
234}
235
236fn validate_optional_str<'a>(
237    field: &str,
238    value: BorrowedStr<'a>,
239    errors: &mut PluginManifestValidationErrors,
240) -> Option<&'a str> {
241    if value.len == 0 {
242        return Some("");
243    }
244
245    if value.ptr.is_null() {
246        errors.push(format!(
247            "{field} has null pointer with non-zero length {}",
248            value.len
249        ));
250        return None;
251    }
252
253    // SAFETY: validation only reads the descriptor supplied by the manifest producer.
254    let bytes = unsafe { slice::from_raw_parts(value.ptr, value.len) };
255    match std::str::from_utf8(bytes) {
256        Ok(text) => Some(text),
257        Err(e) => {
258            errors.push(format!("{field} is not valid UTF-8: {e}"));
259            None
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use std::ptr;
267
268    use rstest::rstest;
269
270    use super::*;
271
272    fn valid_manifest() -> PluginManifest {
273        PluginManifest {
274            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
275            plugin_name: BorrowedStr::from_str("test-plugin"),
276            plugin_vendor: BorrowedStr::from_str("nautech"),
277            plugin_version: BorrowedStr::from_str("1.0.0"),
278            build_id: PluginBuildId::current(),
279        }
280    }
281
282    #[rstest]
283    fn matches_compiled_abi_accepts_compiled_version() {
284        assert!(valid_manifest().matches_compiled_abi());
285    }
286
287    #[rstest]
288    fn matches_compiled_abi_rejects_mismatch() {
289        let manifest = PluginManifest {
290            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
291            ..valid_manifest()
292        };
293
294        assert!(!manifest.matches_compiled_abi());
295    }
296
297    #[rstest]
298    fn validate_accepts_valid_manifest() {
299        valid_manifest().validate().unwrap();
300    }
301
302    #[rstest]
303    fn validate_rejects_mismatched_abi() {
304        let manifest = PluginManifest {
305            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
306            ..valid_manifest()
307        };
308
309        let errors = manifest.validate().unwrap_err();
310        assert_eq!(
311            errors.messages(),
312            &[format!(
313                "abi_version {} does not match supported ABI {}",
314                NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
315                NAUTILUS_PLUGIN_ABI_VERSION
316            )]
317        );
318    }
319
320    #[rstest]
321    fn validate_rejects_missing_name() {
322        let manifest = PluginManifest {
323            plugin_name: BorrowedStr::empty(),
324            ..valid_manifest()
325        };
326
327        let errors = manifest.validate().unwrap_err();
328        assert_eq!(errors.messages(), &["plugin_name must not be empty"]);
329    }
330
331    #[rstest]
332    fn validate_rejects_mismatched_build_schema() {
333        let manifest = PluginManifest {
334            build_id: PluginBuildId {
335                schema_version: PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
336                ..PluginBuildId::current()
337            },
338            ..valid_manifest()
339        };
340
341        let errors = manifest.validate().unwrap_err();
342        assert_eq!(
343            errors.messages(),
344            &[format!(
345                "build_id.schema_version {} does not match supported schema {}",
346                PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
347                PLUGIN_BUILD_ID_VERSION
348            )]
349        );
350    }
351
352    #[rstest]
353    fn validate_rejects_null_pointer_with_nonzero_length() {
354        let mut plugin_name = BorrowedStr::from_str("x");
355        plugin_name.ptr = ptr::null();
356        plugin_name.len = 1;
357        let manifest = PluginManifest {
358            plugin_name,
359            ..valid_manifest()
360        };
361
362        let errors = manifest.validate().unwrap_err();
363        assert_eq!(
364            errors.messages(),
365            &["plugin_name has null pointer with non-zero length 1"]
366        );
367    }
368
369    #[rstest]
370    fn validate_rejects_invalid_utf8() {
371        static INVALID_UTF8: [u8; 1] = [0xFF];
372        let mut plugin_name = BorrowedStr::empty();
373        plugin_name.ptr = INVALID_UTF8.as_ptr();
374        plugin_name.len = INVALID_UTF8.len();
375        let manifest = PluginManifest {
376            plugin_name,
377            ..valid_manifest()
378        };
379
380        let errors = manifest.validate().unwrap_err();
381        assert!(errors.messages()[0].starts_with("plugin_name is not valid UTF-8:"));
382    }
383
384    #[rstest]
385    fn validate_rejects_mismatched_precision_mode() {
386        let wrong_precision_mode = match compiled_precision_mode() {
387            "standard" => "high-precision",
388            _ => "standard",
389        };
390        let manifest = PluginManifest {
391            build_id: PluginBuildId {
392                precision_mode: BorrowedStr::from_str(wrong_precision_mode),
393                ..PluginBuildId::current()
394            },
395            ..valid_manifest()
396        };
397
398        let errors = manifest.validate().unwrap_err();
399        assert_eq!(
400            errors.messages(),
401            &[format!(
402                "build_id.precision_mode '{wrong_precision_mode}' does not match host precision mode '{}'",
403                compiled_precision_mode()
404            )]
405        );
406    }
407
408    #[rstest]
409    fn validate_rejects_mismatched_fixed_precision() {
410        let manifest = PluginManifest {
411            build_id: PluginBuildId {
412                fixed_precision: FIXED_PRECISION.wrapping_add(1),
413                ..PluginBuildId::current()
414            },
415            ..valid_manifest()
416        };
417
418        let errors = manifest.validate().unwrap_err();
419        assert_eq!(
420            errors.messages(),
421            &[format!(
422                "build_id.fixed_precision {} does not match host fixed precision {}",
423                FIXED_PRECISION.wrapping_add(1),
424                FIXED_PRECISION
425            )]
426        );
427    }
428
429    #[rstest]
430    fn validation_errors_display_joins_messages() {
431        let mut errors = PluginManifestValidationErrors::default();
432        assert!(errors.is_empty());
433        errors.push("first");
434        errors.push("second");
435
436        assert!(!errors.is_empty());
437        assert_eq!(errors.to_string(), "first; second");
438    }
439
440    #[rstest]
441    fn current_build_id_matches_compiled_precision() {
442        let build_id = PluginBuildId::current();
443
444        assert_eq!(build_id.schema_version, PLUGIN_BUILD_ID_VERSION);
445        assert_eq!(build_id.fixed_precision, FIXED_PRECISION);
446        // SAFETY: build ID strings are process-lifetime static strings.
447        assert_eq!(
448            unsafe { build_id.precision_mode.as_str() },
449            compiled_precision_mode()
450        );
451    }
452}