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        validate_required_str("plugin_name", self.plugin_name, &mut errors);
169        validate_optional_str("plugin_vendor", self.plugin_vendor, &mut errors);
170        validate_required_str("plugin_version", self.plugin_version, &mut errors);
171        validate_build_id(&self.build_id, &mut errors);
172
173        if errors.is_empty() {
174            Ok(())
175        } else {
176            Err(errors)
177        }
178    }
179}
180
181fn validate_build_id(build_id: &PluginBuildId, errors: &mut PluginManifestValidationErrors) {
182    if build_id.schema_version != PLUGIN_BUILD_ID_VERSION {
183        errors.push(format!(
184            "build_id.schema_version {} does not match supported schema {}",
185            build_id.schema_version, PLUGIN_BUILD_ID_VERSION
186        ));
187        return;
188    }
189
190    validate_optional_str(
191        "build_id.nautilus_plugin_version",
192        build_id.nautilus_plugin_version,
193        errors,
194    );
195    validate_optional_str("build_id.rustc_version", build_id.rustc_version, errors);
196    validate_optional_str("build_id.target_triple", build_id.target_triple, errors);
197    validate_optional_str("build_id.build_profile", build_id.build_profile, errors);
198    if let Some(precision_mode) =
199        validate_required_str("build_id.precision_mode", build_id.precision_mode, errors)
200    {
201        let expected = compiled_precision_mode();
202        if precision_mode != expected {
203            errors.push(format!(
204                "build_id.precision_mode '{precision_mode}' does not match host precision mode '{expected}'"
205            ));
206        }
207    }
208
209    if build_id.fixed_precision != FIXED_PRECISION {
210        errors.push(format!(
211            "build_id.fixed_precision {} does not match host fixed precision {}",
212            build_id.fixed_precision, FIXED_PRECISION
213        ));
214    }
215}
216
217fn validate_required_str<'a>(
218    field: &str,
219    value: BorrowedStr<'a>,
220    errors: &mut PluginManifestValidationErrors,
221) -> Option<&'a str> {
222    let text = validate_optional_str(field, value, errors)?;
223    if text.is_empty() {
224        errors.push(format!("{field} must not be empty"));
225    }
226    Some(text)
227}
228
229fn validate_optional_str<'a>(
230    field: &str,
231    value: BorrowedStr<'a>,
232    errors: &mut PluginManifestValidationErrors,
233) -> Option<&'a str> {
234    if value.len == 0 {
235        return Some("");
236    }
237
238    if value.ptr.is_null() {
239        errors.push(format!(
240            "{field} has null pointer with non-zero length {}",
241            value.len
242        ));
243        return None;
244    }
245
246    // SAFETY: validation only reads the descriptor supplied by the manifest producer.
247    let bytes = unsafe { slice::from_raw_parts(value.ptr, value.len) };
248    match std::str::from_utf8(bytes) {
249        Ok(text) => Some(text),
250        Err(e) => {
251            errors.push(format!("{field} is not valid UTF-8: {e}"));
252            None
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use rstest::rstest;
260
261    use super::*;
262
263    fn valid_manifest() -> PluginManifest {
264        PluginManifest {
265            abi_version: NAUTILUS_PLUGIN_ABI_VERSION,
266            plugin_name: BorrowedStr::from_str("test-plugin"),
267            plugin_vendor: BorrowedStr::from_str("nautech"),
268            plugin_version: BorrowedStr::from_str("1.0.0"),
269            build_id: PluginBuildId::current(),
270        }
271    }
272
273    #[rstest]
274    fn matches_compiled_abi_accepts_compiled_version() {
275        assert!(valid_manifest().matches_compiled_abi());
276    }
277
278    #[rstest]
279    fn matches_compiled_abi_rejects_mismatch() {
280        let manifest = PluginManifest {
281            abi_version: NAUTILUS_PLUGIN_ABI_VERSION.wrapping_add(1),
282            ..valid_manifest()
283        };
284
285        assert!(!manifest.matches_compiled_abi());
286    }
287
288    #[rstest]
289    fn validate_accepts_valid_manifest() {
290        valid_manifest().validate().unwrap();
291    }
292
293    #[rstest]
294    fn validate_rejects_missing_name() {
295        let manifest = PluginManifest {
296            plugin_name: BorrowedStr::empty(),
297            ..valid_manifest()
298        };
299
300        let errors = manifest.validate().unwrap_err();
301        assert_eq!(errors.messages(), &["plugin_name must not be empty"]);
302    }
303
304    #[rstest]
305    fn validate_rejects_mismatched_build_schema() {
306        let manifest = PluginManifest {
307            build_id: PluginBuildId {
308                schema_version: PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
309                ..PluginBuildId::current()
310            },
311            ..valid_manifest()
312        };
313
314        let errors = manifest.validate().unwrap_err();
315        assert_eq!(
316            errors.messages(),
317            &[format!(
318                "build_id.schema_version {} does not match supported schema {}",
319                PLUGIN_BUILD_ID_VERSION.wrapping_add(1),
320                PLUGIN_BUILD_ID_VERSION
321            )]
322        );
323    }
324}