1use 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
26pub type PluginInitFn = unsafe extern "C" fn(host: *const HostVTable) -> *const PluginManifest;
29
30#[repr(C)]
37#[derive(Debug, Clone, Copy)]
38pub struct PluginBuildId {
39 pub schema_version: u32,
42
43 pub nautilus_plugin_version: BorrowedStr<'static>,
45
46 pub rustc_version: BorrowedStr<'static>,
49
50 pub target_triple: BorrowedStr<'static>,
52
53 pub build_profile: BorrowedStr<'static>,
55
56 pub precision_mode: BorrowedStr<'static>,
58
59 pub fixed_precision: u8,
61}
62
63impl PluginBuildId {
64 #[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
91pub struct PluginManifestValidationErrors {
92 messages: Vec<String>,
93}
94
95impl PluginManifestValidationErrors {
96 #[must_use]
98 pub fn is_empty(&self) -> bool {
99 self.messages.is_empty()
100 }
101
102 #[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#[repr(C)]
134#[derive(Debug)]
135pub struct PluginManifest {
136 pub abi_version: u32,
139
140 pub plugin_name: BorrowedStr<'static>,
142
143 pub plugin_vendor: BorrowedStr<'static>,
145
146 pub plugin_version: BorrowedStr<'static>,
148
149 pub build_id: PluginBuildId,
151}
152
153impl PluginManifest {
154 #[must_use]
156 pub fn matches_compiled_abi(&self) -> bool {
157 self.abi_version == NAUTILUS_PLUGIN_ABI_VERSION
158 }
159
160 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 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 assert_eq!(
447 unsafe { build_id.precision_mode.as_str() },
448 compiled_precision_mode()
449 );
450 }
451}