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> {
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 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 assert_eq!(
448 unsafe { build_id.precision_mode.as_str() },
449 compiled_precision_mode()
450 );
451 }
452}