diff --git a/Cargo.toml b/Cargo.toml index fdc181dd..9e409cac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,6 @@ members = [ "bottlerocket-settings-sdk", "bottlerocket-template-helper", "bottlerocket-defaults-helper", + "bottlerocket-settings-plugin", + "bottlerocket-settings-derive", ] diff --git a/bottlerocket-settings-derive/Cargo.toml b/bottlerocket-settings-derive/Cargo.toml new file mode 100644 index 00000000..fd2afad4 --- /dev/null +++ b/bottlerocket-settings-derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bottlerocket-settings-derive" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk" +readme = "../README.md" + +[lib] +proc-macro = true + +[dependencies] +darling = "0.20.8" +proc-macro2 = "1.0.81" +quote = "1.0.36" +syn = "2.0.60" diff --git a/bottlerocket-settings-derive/src/lib.rs b/bottlerocket-settings-derive/src/lib.rs new file mode 100644 index 00000000..7c549322 --- /dev/null +++ b/bottlerocket-settings-derive/src/lib.rs @@ -0,0 +1,71 @@ +/*! +This crate provides a derive macro for implementing the provider side of a Bottlerocket settings +plugin. It should be applied to a custom settings struct in the cdylib crate, and implements the +FFI protocol expected by the host process that will load the plugin. +*/ + +use darling::{FromDeriveInput, ToTokens}; +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput}; + +/// A macro to simplify implementing a settings plugin. +#[proc_macro_derive(SettingsPlugin)] +pub fn derive_settings(input: TokenStream) -> TokenStream { + // Parse the AST and "deserialize" into SettingsPlugin + let ast = parse_macro_input!(input as DeriveInput); + let n = SettingsPlugin::from_derive_input(&ast).expect("Unable to parse macro arguments"); + quote!(#n).into() +} + +#[derive(Debug, FromDeriveInput)] +#[darling(supports(struct_named))] +struct SettingsPlugin { + ident: syn::Ident, +} + +impl ToTokens for SettingsPlugin { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let SettingsPlugin { ident } = self; + tokens.extend(quote! { + // Provide the "serialize" interface expected for this type. + impl<'a> abi_stable::erased_types::SerializeType<'a> for #ident { + type Interface = bottlerocket_settings_plugin::BottlerocketSettingsInterface; + + fn serialize_impl(&'a self) -> Result { + // Call the shared function to serialize to JSON. + bottlerocket_settings_plugin::serialize_json(self) + } + } + + // Provide the "deserialize" function that's required for FFI. + // This function refers to the type, but isn't otherwise tied to it or namespaced in + // any way, which means the derive macro can't be used for more than one type in the + // module. + #[abi_stable::sabi_extern_fn] + fn deserialize_settings(s: abi_stable::std_types::RStr<'_>) -> abi_stable::std_types::RResult { + // Call the shared function to deserialize from JSON. + bottlerocket_settings_plugin::deserialize_json::<#ident>(s).map(abi_stable::DynTrait::from_value) + } + + // Provide the "defaults" function that's required for FFI. + // This function also refers to the type, with the same caveats as above. + #[abi_stable::sabi_extern_fn] + fn default_settings() -> bottlerocket_settings_plugin::BottlerocketSettingsProvider { + // Requires a Default impl on the type. + abi_stable::DynTrait::from_value(#ident::default()) + } + + // Make the `deserialize_settings` and `default_settings` functions available via FFI + // as the exported interface for this plugin. + #[abi_stable::export_root_module] + fn get_library() -> bottlerocket_settings_plugin::BottlerocketSettingsPluginRef { + abi_stable::prefix_type::PrefixTypeTrait::leak_into_prefix( + bottlerocket_settings_plugin::BottlerocketSettingsPlugin { + default_settings, + deserialize_settings, + }) + } + }); + } +} diff --git a/bottlerocket-settings-plugin/Cargo.toml b/bottlerocket-settings-plugin/Cargo.toml new file mode 100644 index 00000000..50b8621e --- /dev/null +++ b/bottlerocket-settings-plugin/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bottlerocket-settings-plugin" +version = "0.1.0" +license = "Apache-2.0 OR MIT" +edition = "2021" +repository = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk" +readme = "../README.md" + +[dependencies] +abi_stable = "0.11.3" +lazy_static = "1.4.0" +serde = "1.0.198" +serde_json = "1.0.116" +bottlerocket-settings-derive = { version = "0.1.0", path = "../bottlerocket-settings-derive" } diff --git a/bottlerocket-settings-plugin/src/lib.rs b/bottlerocket-settings-plugin/src/lib.rs new file mode 100644 index 00000000..91fce5f1 --- /dev/null +++ b/bottlerocket-settings-plugin/src/lib.rs @@ -0,0 +1,21 @@ +/*! +This crate defines the FFI specification for Bottlerocket settings plugins. + +The goal of a settings plugin is to enable a host program to construct and serialize instances of a +Rust struct without compile-time access to its definition. Instead, the struct is defined by a +cdylib crate, which can be loaded at runtime into the host program as a plugin. The host program +cannot access fields or methods on this type directly, only through functions exposed via FFI. + +The crate also provides helper functionality that can be used by either the host program or by +plugins, to make the shared settings structure easier to implement and to interact with from +idiomatic Rust. + +All of the heavy lifting is handled by the abi_stable crate, which provides FFI-safe wrapper types +and an interface for loading and verifying cdylibs at runtime. +*/ + +mod settings; +pub use settings::*; + +// Export the derive macro via this crate, since it depends on the implementation details here. +pub use bottlerocket_settings_derive::SettingsPlugin; diff --git a/bottlerocket-settings-plugin/src/settings.rs b/bottlerocket-settings-plugin/src/settings.rs new file mode 100644 index 00000000..64162bbf --- /dev/null +++ b/bottlerocket-settings-plugin/src/settings.rs @@ -0,0 +1,232 @@ +/*! +This crate defines the FFI specification for Bottlerocket settings plugins, as well as some helper +functions. +*/ + +// Avoid empty doc comment warning that originates from the StableAbi derive macro. +#![allow(clippy::empty_docs)] +// Avoid false positive improper ctypes warnings for abi_stable's PhantomData markers. We rely on +// the StableAbi trait to catch any real problems. +#![allow(improper_ctypes_definitions)] + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value as JsonValue; +use std::path::PathBuf; + +use abi_stable::{ + erased_types::{DeserializeDyn, DynTrait, SerializeProxyType}, + external_types::{RawValueBox, RawValueRef}, + library::RootModule, + package_version_strings, + sabi_types::VersionStrings, + std_types::{RBox, RBoxError, RErr, ROk, RResult, RStr}, + StableAbi, +}; + +use lazy_static::lazy_static; + +const SETTINGS: &str = "settings"; + +/// Plugins need to provide "default" and "deserialize" functions that return an instance of the +/// opaque BottlerocketSettingsProvider wrapper type. These are the only way for the host program +/// to construct new instances of the underlying concrete type. +#[repr(C)] +#[derive(StableAbi)] +#[sabi(kind(Prefix(prefix_ref = BottlerocketSettingsPluginRef)))] +#[sabi(missing_field(panic))] +pub struct BottlerocketSettingsPlugin { + /// Returns a BottlerocketSettingsProvider that wraps a new instance of the underlying type + /// which was created with default values. + pub default_settings: extern "C" fn() -> BottlerocketSettingsProvider, + + #[sabi(last_prefix_field)] + /// Returns a BottlerocketSettingsProvider that wraps a new instance of the underlying type + /// which was created by deserializing the supplied string. + pub deserialize_settings: + for<'a> extern "C" fn(RStr<'a>) -> RResult, +} + +/// These values will be checked at runtime to ensure that the host program and the plugin agree +/// on the name and version of the expected interface. +impl RootModule for BottlerocketSettingsPluginRef { + const BASE_NAME: &'static str = SETTINGS; + const NAME: &'static str = SETTINGS; + const VERSION_STRINGS: VersionStrings = package_version_strings!(); + abi_stable::declare_root_module_statics! {BottlerocketSettingsPluginRef} +} + +// Shared library plugins should only be loaded once, cannot be unloaded, and might not be safe to +// try loading again if the first try fails. Whatever result we get from this attempt is what we'll +// live with. +lazy_static! { + static ref PLUGIN: BottlerocketSettingsPluginRef = { + match BottlerocketSettingsPluginRef::load_from_file(&PathBuf::from(format!( + "lib{}.{}", + BottlerocketSettingsPluginRef::NAME, + std::env::consts::DLL_EXTENSION, + ))) { + Ok(r) => r, + Err(e) => { + panic!("Fatal error when loading settings plugin: {e}"); + } + } + }; +} + +/// Provide an interface to load the settings plugin dynamically the first time it's required. +/// Panics if the plugin cannot be loaded. This simplifies loading the plugin since programs do +/// not need to arrange to call an initialization function before the first call to a nominally +/// infallible trait impl like Default. +impl BottlerocketSettingsPluginRef { + pub fn load() { + let _ = *PLUGIN; + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// Specify all required impls for the wrapped type that will be required by the plugin. +#[repr(C)] +#[derive(StableAbi)] +#[sabi(impl_InterfaceType( + Sync, + Send, + Default, + Eq, + PartialEq, + Clone, + Debug, + Deserialize, + Serialize +))] +pub struct BottlerocketSettingsInterface; + +/// Implement the proxy serialization trait for the wrapped type. +impl<'a> SerializeProxyType<'a> for BottlerocketSettingsInterface { + // Serialize the type by way of a boxed serde_json raw value. + type Proxy = RawValueBox; + // There's no need to load the plugin to serialize the type, because the type can only be + // instantiated by the default and deserialize functions, which trigger the plugin load. +} + +/// Implement the proxy deserialization trait for the wrapped type. +impl<'a> DeserializeDyn<'a, BottlerocketSettingsProvider> for BottlerocketSettingsInterface { + /// Deserialize the type by way of a serde_json raw value ref. + type Proxy = RawValueRef<'a>; + + // Load the plugin, then pass the provided input to the deserialize function via FFI. + fn deserialize_dyn(s: Self::Proxy) -> Result { + BottlerocketSettingsPluginRef::load(); + BottlerocketSettingsPluginRef::get_module() + .unwrap() + .deserialize_settings()(s.get_rstr()) + .into_result() + } +} + +/// Define the boxed wrapper type returned by FFI functions. +pub type BottlerocketSettingsProvider = DynTrait<'static, RBox<()>, BottlerocketSettingsInterface>; + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// The Default trait is already used for `DynTrait`, so add a custom trait to provide the same +/// behavior. +pub trait BottlerocketDefaults: Sized { + fn defaults() -> Self; +} + +/// Implement the custom default trait for the boxed wrapper type. +impl BottlerocketDefaults for BottlerocketSettingsProvider { + // Load the plugin, then call the defaults function via FFI. + fn defaults() -> Self { + BottlerocketSettingsPluginRef::load(); + BottlerocketSettingsPluginRef::get_module() + .unwrap() + .default_settings()() + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// Helper function that plugins can use to implement the deserialize function. +/// This runs on the plugin side of the FFI boundary. +pub fn deserialize_json<'a, T>(s: RStr<'a>) -> RResult +where + T: serde::Deserialize<'a>, +{ + match serde_json::from_str::(s.into()) { + Ok(x) => ROk(x), + Err(e) => RErr(RBoxError::new(e)), + } +} + +/// Helper function that plugins can use to implement the serialize function. +/// This runs on the plugin side of the FFI boundary. +pub fn serialize_json(value: &T) -> Result +where + T: serde::Serialize, +{ + match serde_json::value::to_raw_value::(value) { + Ok(v) => Ok(v.into()), + Err(e) => Err(RBoxError::new(e)), + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// BottlerocketSettings is a wrapper type for the provider wrapper type. It's the preferred way +/// for the host program to interact with the plugin. It provides Serialize, Deserialize, and +/// Default impls that handle some of the quirks that arise when dealing directly with the +/// provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BottlerocketSettings(BottlerocketSettingsProvider); + +/// Serialize impl that goes through an intermediate JSON value so that type data is available to +/// the host program. +impl Serialize for BottlerocketSettings { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // First serialize the wrapped type to a string, using the plugin's impl. + let json_string = serde_json::to_string(&self.0).map_err(serde::ser::Error::custom)?; + + // Now turn the string into a JSON value, which is a type that the host program can + // introspect. + let json_value = + serde_json::from_str::(&json_string).map_err(serde::ser::Error::custom)?; + + // Pass the JSON value into the provided serializer. + json_value.serialize(serializer) + } +} + +/// Deserialize impl that goes through an intermediate JSON value so that type data is available to +/// the host program. +impl<'de> Deserialize<'de> for BottlerocketSettings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // First deserialize into a JSON value using the provided deserializer, which is a type + // that the host program knows how to construct. + let json_value = JsonValue::deserialize(deserializer)?; + + // Now turn the JSON value back into a JSON string to send across the FFI boundary. + let json_string = serde_json::to_string(&json_value).map_err(serde::de::Error::custom)?; + + // Deserialize the wrapped type from the JSON string using the plugin's impl. + Ok(Self( + serde_json::from_str::(&json_string) + .map_err(serde::de::Error::custom)?, + )) + } +} + +/// Default impl that calls the custom defaults trait on the provider wrapper type. +impl Default for BottlerocketSettings { + fn default() -> Self { + let defaults = BottlerocketSettingsProvider::defaults(); + Self(defaults) + } +} diff --git a/deny.toml b/deny.toml index e4f710af..b9b2bc64 100644 --- a/deny.toml +++ b/deny.toml @@ -11,14 +11,15 @@ allow = [ "BSD-3-Clause", "BSL-1.0", # "CC0-1.0", - # "ISC", + "ISC", "MIT", # "OpenSSL", # "Unlicense", - # "Zlib", + "Zlib", ] exceptions = [ + { name = "generational-arena", allow = ["MPL-2.0"] }, { name = "unicode-ident", allow = ["MIT", "Apache-2.0", "Unicode-DFS-2016"] }, ] @@ -26,3 +27,8 @@ exceptions = [ # Deny multiple versions or wildcard dependencies. multiple-versions = "deny" wildcards = "deny" + +skip = [ + # abi_stable is using an older version of syn + { name = "syn", version = "1" }, +]