diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f456c1be..2c1e6c856 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,6 +22,7 @@ data-pipeline*/ @Datadog/libdatadog-apm datadog-tracer-flare @Datadog/libdatadog-apm ddsketch @Datadog/libdatadog-apm @Datadog/libdatadog-telemetry datadog-ffe @Datadog/feature-flagging-and-experimentation-sdk +datadog-ffe-ffi @Datadog/feature-flagging-and-experimentation-sdk # Most of the bin_tests are owned by the profiling team, but some are owned by the core team bin_tests/ @Datadog/libdatadog-profiling diff --git a/Cargo.lock b/Cargo.lock index d086c3e0c..f967e5409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,17 @@ dependencies = [ "url", ] +[[package]] +name = "datadog-ffe-ffi" +version = "0.1.0" +dependencies = [ + "anyhow", + "build_common", + "datadog-ffe", + "function_name", + "libdd-common-ffi", +] + [[package]] name = "datadog-ipc" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6c6cbe51c..f49aac285 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "datadog-crashtracker", "datadog-crashtracker-ffi", "datadog-ffe", + "datadog-ffe-ffi", "datadog-ipc", "datadog-ipc-macros", "libdd-library-config", diff --git a/LICENSE-3rdparty.yml b/LICENSE-3rdparty.yml index 8e6e6f598..3d3069f7c 100644 --- a/LICENSE-3rdparty.yml +++ b/LICENSE-3rdparty.yml @@ -1,4 +1,4 @@ -root_name: builder, build_common, tools, libdd-alloc, datadog-crashtracker, libdd-common, libdd-telemetry, libdd-ddsketch, datadog-crashtracker-ffi, libdd-common-ffi, datadog-ffe, datadog-ipc, datadog-ipc-macros, libdd-tinybytes, tarpc, tarpc-plugins, spawn_worker, cc_utils, libdd-library-config, libdd-library-config-ffi, datadog-live-debugger, datadog-live-debugger-ffi, datadog-profiling, libdd-profiling-protobuf, datadog-profiling-ffi, libdd-data-pipeline-ffi, libdd-data-pipeline, libdd-dogstatsd-client, libdd-trace-protobuf, libdd-trace-stats, libdd-trace-utils, libdd-trace-normalization, libdd-log, libdd-ddsketch-ffi, libdd-log-ffi, libdd-telemetry-ffi, symbolizer-ffi, datadog-profiling-replayer, datadog-remote-config, datadog-sidecar, datadog-sidecar-macros, datadog-sidecar-ffi, datadog-trace-obfuscation, datadog-tracer-flare, sidecar_mockgen, test_spawn_from_lib +root_name: builder, build_common, tools, libdd-alloc, datadog-crashtracker, libdd-common, libdd-telemetry, libdd-ddsketch, datadog-crashtracker-ffi, libdd-common-ffi, datadog-ffe, datadog-ffe-ffi, datadog-ipc, datadog-ipc-macros, libdd-tinybytes, tarpc, tarpc-plugins, spawn_worker, cc_utils, libdd-library-config, libdd-library-config-ffi, datadog-live-debugger, datadog-live-debugger-ffi, datadog-profiling, libdd-profiling-protobuf, datadog-profiling-ffi, libdd-data-pipeline-ffi, libdd-data-pipeline, libdd-dogstatsd-client, libdd-trace-protobuf, libdd-trace-stats, libdd-trace-utils, libdd-trace-normalization, libdd-log, libdd-ddsketch-ffi, libdd-log-ffi, libdd-telemetry-ffi, symbolizer-ffi, datadog-profiling-replayer, datadog-remote-config, datadog-sidecar, datadog-sidecar-macros, datadog-sidecar-ffi, datadog-trace-obfuscation, datadog-tracer-flare, sidecar_mockgen, test_spawn_from_lib third_party_libraries: - package_name: addr2line package_version: 0.24.2 @@ -27693,9 +27693,9 @@ third_party_libraries: - package_name: stringmetrics package_version: 2.2.2 repository: https://github.com/pluots/stringmetrics - license: License specified in file ($CARGO_HOME/registry/src/index.crates.io-6f17d22bba15001f/stringmetrics-2.2.2/LICENSE) + license: License specified in file ($CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/stringmetrics-2.2.2/LICENSE) licenses: - - license: License specified in file ($CARGO_HOME/registry/src/index.crates.io-6f17d22bba15001f/stringmetrics-2.2.2/LICENSE) + - license: License specified in file ($CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/stringmetrics-2.2.2/LICENSE) text: | Copyright 2022 Trevor Gross diff --git a/datadog-ffe-ffi/Cargo.toml b/datadog-ffe-ffi/Cargo.toml new file mode 100644 index 000000000..8a8848533 --- /dev/null +++ b/datadog-ffe-ffi/Cargo.toml @@ -0,0 +1,28 @@ +# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "datadog-ffe-ffi" +edition.workspace = true +version = "0.1.0" +rust-version.workspace = true +license.workspace = true + +[lib] +crate-type = ["lib", "staticlib", "cdylib"] +bench = false + +[features] +default = ["cbindgen"] +cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] + +[build-dependencies] +build_common = { path = "../build-common" } + +[dependencies] +anyhow = "1.0.93" +datadog-ffe = { path = "../datadog-ffe", version = "=0.1.0" } +libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } +function_name = "0.3.0" + +[dev-dependencies] diff --git a/datadog-ffe-ffi/build.rs b/datadog-ffe-ffi/build.rs new file mode 100644 index 000000000..4b6fe1d42 --- /dev/null +++ b/datadog-ffe-ffi/build.rs @@ -0,0 +1,11 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +extern crate build_common; + +use build_common::generate_and_configure_header; + +fn main() { + println!("cargo:rerun-if-changed=src/*"); + let header_name = "datadog_ffe.h"; + generate_and_configure_header(header_name); +} diff --git a/datadog-ffe-ffi/cbindgen.toml b/datadog-ffe-ffi/cbindgen.toml new file mode 100644 index 000000000..54dc87720 --- /dev/null +++ b/datadog-ffe-ffi/cbindgen.toml @@ -0,0 +1,41 @@ +# Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +language = "C" +cpp_compat = true +tab_width = 2 +header = """// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +""" +include_guard = "DDOG_FFE_H" +style = "tag" +usize_is_size_t = true +pragma_once = true + +no_includes = true +sys_includes = ["stdbool.h", "stddef.h", "stdint.h"] +includes = ["common.h"] + +[export] +include = ["datadog-ffe-ffi"] +prefix = "ddog_ffe_" +renaming_overrides_prefixing = true + +[export.rename] +"VoidResult" = "ddog_VoidResult" +"Error" = "ddog_Error" +"Vec_u8" = "ddog_Vec_U8" + +[export.mangle] +rename_types = "PascalCase" + +[enum] +prefix_with_name = true +rename_variants = "ScreamingSnakeCase" + +[fn] +must_use = "DDOG_CHECK_RETURN" + +[parse] +parse_deps = true +include = ["libdd-common-ffi", "datadog-ffe"] diff --git a/datadog-ffe-ffi/src/assignment.rs b/datadog-ffe-ffi/src/assignment.rs new file mode 100644 index 000000000..afe146992 --- /dev/null +++ b/datadog-ffe-ffi/src/assignment.rs @@ -0,0 +1,485 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{c_char, CStr}; + +use datadog_ffe::rules_based as ffe; +use datadog_ffe::rules_based::{ + now, Assignment, AssignmentReason, AssignmentValue, Configuration, EvaluationContext, + EvaluationError, Str, +}; + +use crate::Handle; + +/// Opaque type representing a result of evaluation. +pub struct ResolutionDetails { + inner: Result, + // memoizing some fields, so we can hand off references to them: + error_message: Option, + extra_logging: Vec>, + flag_metadata: Vec>, +} +impl ResolutionDetails { + fn new(value: Result) -> ResolutionDetails { + let error_message = value.as_ref().err().map(|err| err.to_string()); + + let extra_logging = value + .as_ref() + .iter() + .flat_map(|it| it.extra_logging.iter()) + .map(|(k, v)| { + KeyValue { + // SAFETY: the borrow is valid as long as string allocation is + // alive. ResolutionDetails will get moved into heap but this does not + // invalidate the string. + key: unsafe { BorrowedStr::borrow_from_str(k.as_str()) }, + // SAFETY: the borrow is valid as long as string allocation is + // alive. ResolutionDetails will get moved into heap but this does not + // innvalidate the string. + value: unsafe { BorrowedStr::borrow_from_str(v.as_str()) }, + } + }) + .collect(); + + let flag_metadata = match value.as_ref() { + Ok(a) => { + vec![KeyValue { + // SAFETY: borrowing from static is safe as it lives long enough. + key: unsafe { BorrowedStr::borrow_from_str("allocation_key") }, + // SAFETY: allocation_key is alive until ResolutionDetails is dropped. + value: unsafe { BorrowedStr::borrow_from_str(a.allocation_key.as_str()) }, + }] + } + Err(_) => Vec::new(), + }; + + ResolutionDetails { + inner: value, + error_message, + extra_logging, + flag_metadata, + } + } +} +impl From for ResolutionDetails { + fn from(value: Assignment) -> Self { + ResolutionDetails::new(Ok(value)) + } +} +impl From for ResolutionDetails { + fn from(value: EvaluationError) -> Self { + ResolutionDetails::new(Err(value)) + } +} +impl From> for ResolutionDetails { + fn from(value: Result) -> Self { + ResolutionDetails::new(value) + } +} +impl AsRef> for ResolutionDetails { + fn as_ref(&self) -> &Result { + &self.inner + } +} + +#[repr(C)] +pub enum ExpectedFlagType { + String, + Integer, + Float, + Boolean, + Object, + Number, + Any, +} +impl From for ffe::ExpectedFlagType { + fn from(value: ExpectedFlagType) -> ffe::ExpectedFlagType { + match value { + ExpectedFlagType::String => ffe::ExpectedFlagType::String, + ExpectedFlagType::Integer => ffe::ExpectedFlagType::Integer, + ExpectedFlagType::Float => ffe::ExpectedFlagType::Float, + ExpectedFlagType::Boolean => ffe::ExpectedFlagType::Boolean, + ExpectedFlagType::Object => ffe::ExpectedFlagType::Object, + ExpectedFlagType::Number => ffe::ExpectedFlagType::Number, + ExpectedFlagType::Any => ffe::ExpectedFlagType::Any, + } + } +} + +#[repr(C)] +pub enum FlagType { + Unknown, + String, + Integer, + Float, + Boolean, + Object, +} + +impl From for FlagType { + fn from(value: ffe::FlagType) -> Self { + match value { + ffe::FlagType::String => FlagType::String, + ffe::FlagType::Integer => FlagType::Integer, + ffe::FlagType::Float => FlagType::Float, + ffe::FlagType::Boolean => FlagType::Boolean, + ffe::FlagType::Object => FlagType::Object, + } + } +} + +#[derive(Debug, PartialEq)] +#[repr(C)] +pub enum ErrorCode { + Ok, + TypeMismatch, + ParseError, + FlagNotFound, + TargetingKeyMissing, + InvalidContext, + ProviderNotReady, + General, +} + +#[repr(C)] +pub enum Reason { + Static, + Default, + TargetingMatch, + Split, + Disabled, + Error, +} + +/// Evaluates a feature flag. +/// +/// # Ownership +/// +/// The caller must call `ddog_ffe_assignment_drop` on the returned value to free resources. +/// +/// # Safety +/// +/// - `config` must be a valid `Configuration` handle +/// - `flag_key` must be a valid C string +/// - `context` must be a valid `EvaluationContext` handle +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_get_assignment( + config: Handle, + flag_key: *const c_char, + expected_type: ExpectedFlagType, + context: Handle, +) -> Handle { + if flag_key.is_null() { + return Handle::new( + EvaluationError::Internal(Str::from_static_str( + "ddog_ffe_get_assignment: flag_key must not be NULL", + )) + .into(), + ); + } + + // SAFETY: the caller must ensure that configuration handle is valid + let config = unsafe { config.as_ref() }; + // SAFETY: the caller must ensure that context handle is valid + let context = unsafe { context.as_ref() }; + + // SAFETY: we checked that flag_key is not NULL. + let Ok(flag_key) = unsafe { CStr::from_ptr(flag_key) }.to_str() else { + return Handle::new( + EvaluationError::Internal(Str::from_static_str( + "ddog_ffe_get_assignment: flag_key is not a valid UTF-8 string", + )) + .into(), + ); + }; + + let assignment_result = config.eval_flag(flag_key, context, expected_type.into(), now()); + + Handle::new(assignment_result.into()) +} + +#[repr(C)] +pub enum VariantValue { + /// Evaluation did not produce any value. + None, + String(BorrowedStr), + Integer(i64), + Float(f64), + Boolean(bool), + Object(BorrowedStr), +} + +/// A string that has been borrowed. Beware that it is NOT nul-terminated! +/// +/// # Ownership +/// +/// This string is non-owning. You must not free `ptr`. +/// +/// # Safety +/// +/// - The string is not NUL-terminated, it can only be used with API that accept the len as an +/// additional parameter. +/// - The value must not be used after the value it borrowed from has been moved, modified, or +/// freed. +#[repr(C)] +pub struct BorrowedStr { + /// May be NULL if `len` is `0`. + pub ptr: *const u8, + pub len: usize, +} +impl BorrowedStr { + #[inline] + pub(crate) unsafe fn as_bytes(&self) -> &[u8] { + // SAFETY: the caller must ensure that ptr and len are valid. + unsafe { std::slice::from_raw_parts(self.ptr, self.len) } + } +} + +#[repr(C)] +pub struct KeyValue { + pub key: K, + pub value: V, +} +#[repr(C)] +pub struct ArrayMap { + pub elements: *const KeyValue, + pub count: usize, +} +impl ArrayMap { + /// # Safety + /// - The returned value must not outlive `slice`. + unsafe fn borrow_from_slice(slice: &[KeyValue]) -> ArrayMap { + ArrayMap { + elements: slice.as_ptr(), + count: slice.len(), + } + } +} + +impl BorrowedStr { + /// Borrow string from `s`. + /// + /// # Safety + /// + /// - The returned value must non outlive `s`. + /// - `s` must not be modified while `BorrowedStr` is alive. + #[inline] + unsafe fn borrow_from_str(s: &str) -> BorrowedStr { + BorrowedStr { + ptr: s.as_ptr(), + len: s.len(), + } + } + + #[inline] + const fn empty() -> BorrowedStr { + BorrowedStr { + ptr: std::ptr::null(), + len: 0, + } + } +} + +/// Get value produced by evaluation. +/// +/// # Ownership +/// +/// The returned `VariantValue` borrows from `assignment`. It must not be used after `assignment` is +/// freed. +/// +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_value( + assignment: Handle, +) -> VariantValue { + // SAFETY: the caller must ensure that assignment is valid. + match unsafe { assignment.as_ref() }.as_ref() { + Ok(assignment) => match &assignment.value { + AssignmentValue::String(s) => { + // SAFETY: caller is required to not use return value after freeing + // `assignment`. + VariantValue::String(unsafe { BorrowedStr::borrow_from_str(s.as_str()) }) + } + AssignmentValue::Integer(v) => VariantValue::Integer(*v), + AssignmentValue::Float(v) => VariantValue::Float(*v), + AssignmentValue::Boolean(v) => VariantValue::Boolean(*v), + AssignmentValue::Json { value: _, raw } => { + // SAFETY: caller is required to not use return value after freeing + // `assignment`. + VariantValue::Object(unsafe { BorrowedStr::borrow_from_str(raw.get()) }) + } + }, + _ => VariantValue::None, + } +} + +/// Get variant key produced by evaluation. Returns `NULL` if evaluation did not produce any value. +/// +/// # Ownership +/// +/// The returned string borrows from `assignment`. It must not be used after `assignment` is +/// freed. +/// +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_variant( + assignment: Handle, +) -> BorrowedStr { + // SAFETY: the caller must ensure that assignment is valid. + match unsafe { assignment.as_ref() }.as_ref() { + Ok(assignment) => + // SAFETY: caller is required to not use return value after freeing `assignment`. + unsafe { BorrowedStr::borrow_from_str(&assignment.variation_key) }, + _ => BorrowedStr::empty(), + } +} + +/// Get allocation key produced by evaluation. Returns `NULL` if evaluation did not produce any +/// value. +/// +/// # Ownership +/// +/// The returned string borrows from `assignment`. It must not be used after `assignment` is +/// freed. +/// +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_allocation_key( + assignment: Handle, +) -> BorrowedStr { + // SAFETY: the caller must ensure that assignment is valid. + match unsafe { assignment.as_ref() }.as_ref() { + // SAFETY: caller is required to not use return value after freeing + // `assignment`. + Ok(assignment) => unsafe { + BorrowedStr::borrow_from_str(assignment.allocation_key.as_str()) + }, + _ => BorrowedStr::empty(), + } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_reason( + assignment: Handle, +) -> Reason { + // SAFETY: the caller must ensure that assignment is valid + Reason::from(unsafe { assignment.as_ref() }) +} +impl From<&ResolutionDetails> for Reason { + fn from(value: &ResolutionDetails) -> Self { + match value.as_ref() { + Ok(assignment) => assignment.reason.into(), + Err(EvaluationError::FlagDisabled) => Reason::Disabled, + Err(EvaluationError::DefaultAllocationNull) => Reason::Default, + Err(_) => Reason::Error, + } + } +} +impl From for Reason { + fn from(value: AssignmentReason) -> Self { + match value { + AssignmentReason::TargetingMatch => Reason::TargetingMatch, + AssignmentReason::Split => Reason::Split, + AssignmentReason::Static => Reason::Static, + } + } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_error_code( + assignment: Handle, +) -> ErrorCode { + // SAFETY: the caller must ensure that assignment is valid + ErrorCode::from(unsafe { assignment.as_ref() }) +} +impl From<&ResolutionDetails> for ErrorCode { + fn from(value: &ResolutionDetails) -> Self { + match value.as_ref() { + Ok(_) => ErrorCode::Ok, + Err(err) => ErrorCode::from(err), + } + } +} +impl From<&EvaluationError> for ErrorCode { + fn from(value: &EvaluationError) -> Self { + match value { + EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch, + EvaluationError::ConfigurationParseError => ErrorCode::ParseError, + EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady, + EvaluationError::FlagUnrecognizedOrDisabled => ErrorCode::FlagNotFound, + EvaluationError::FlagDisabled => ErrorCode::Ok, + EvaluationError::DefaultAllocationNull => ErrorCode::Ok, + EvaluationError::Internal(_) => ErrorCode::General, + _ => ErrorCode::General, + } + } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_error_message( + assignment: Handle, +) -> BorrowedStr { + // SAFETY: the caller must ensure that assignment is valid + let assignment = unsafe { assignment.as_ref() }; + match assignment.error_message.as_ref() { + // SAFETY: the caller must not use returned value after assignment is freed. + Some(s) => unsafe { BorrowedStr::borrow_from_str(s.as_str()) }, + None => BorrowedStr::empty(), + } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_get_do_log( + assignment: Handle, +) -> bool { + // SAFETY: the caller must ensure that assignment handle is valid. + match unsafe { assignment.as_ref() }.as_ref() { + Ok(a) => a.do_log, + Err(_) => false, + } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignnment_get_flag_metadata( + assignment: Handle, +) -> ArrayMap { + // SAFETY: the caller must ensure that assignment is valid + let a = unsafe { assignment.as_ref() }; + // SAFETY: the caller must ensure that returned value is not used after `assignment` is freed. + unsafe { ArrayMap::borrow_from_slice(&a.flag_metadata) } +} + +/// # Safety +/// `assignment` must be a valid handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignnment_get_extra_logging( + assignment: Handle, +) -> ArrayMap { + // SAFETY: the caller must ensure that assignment is valid + let a = unsafe { assignment.as_ref() }; + // SAFETY: the caller must ensure that returned value is not used after `assignment` is freed. + unsafe { ArrayMap::borrow_from_slice(&a.extra_logging) } +} + +/// Frees an Assignment handle. +/// +/// # Safety +/// - `assignment` must be a valid Assignment handle +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle) { + // SAFETY: the caller must ensure that assignment is valid + unsafe { Handle::free(assignment) } +} diff --git a/datadog-ffe-ffi/src/configuration.rs b/datadog-ffe-ffi/src/configuration.rs new file mode 100644 index 000000000..77e630ea3 --- /dev/null +++ b/datadog-ffe-ffi/src/configuration.rs @@ -0,0 +1,49 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::ensure; +use function_name::named; + +use datadog_ffe::rules_based::{Configuration, UniversalFlagConfig}; +use libdd_common_ffi::{wrap_with_ffi_result, Result}; + +use crate::{BorrowedStr, Handle}; + +/// Creates a new Configuration from JSON bytes. +/// +/// # Ownership +/// +/// The caller must call `ddog_ffe_configuration_drop` to release resources allocated for +/// configuration. +/// +/// # Safety +/// +/// - `json_bytes` must point to valid memory. +#[no_mangle] +#[named] +pub unsafe extern "C" fn ddog_ffe_configuration_new( + json_bytes: BorrowedStr, +) -> Result> { + wrap_with_ffi_result!({ + ensure!(!json_bytes.ptr.is_null(), "json_str must not be NULL"); + + // SAFETY: the caller must ensure that it's a valid pointer, we also checked for null + let json_bytes = unsafe { json_bytes.as_bytes() }.to_vec(); + + let configuration = + Configuration::from_server_response(UniversalFlagConfig::from_json(json_bytes)?); + + Ok(Handle::from(configuration)) + }) +} + +/// Frees a Configuration. +/// +/// # Safety +/// +/// `config` must be a valid Configuration handle created by `ddog_ffe_configuration_new`. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_configuration_drop(config: *mut Handle) { + // SAFETY: the caller must ensure that config is a valid handle + unsafe { Handle::free(config) }; +} diff --git a/datadog-ffe-ffi/src/evaluation_context.rs b/datadog-ffe-ffi/src/evaluation_context.rs new file mode 100644 index 000000000..ebab8f935 --- /dev/null +++ b/datadog-ffe-ffi/src/evaluation_context.rs @@ -0,0 +1,95 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::ffi::{c_char, CStr}; +use std::sync::Arc; + +use datadog_ffe::rules_based::{Attribute, EvaluationContext, Str}; + +use crate::Handle; + +/// Represents a key-value pair for attributes. +/// +/// # Safety +/// - `name` must be a valid C string. +#[repr(C)] +pub struct AttributePair { + pub name: *const c_char, + pub value: AttributeValue, +} + +/// # Safety +/// - `string` must be a valid C string. +#[repr(C)] +pub enum AttributeValue { + String(*const c_char), + Number(f64), + Boolean(bool), +} + +/// Creates a new EvaluationContext with the given targeting key and attributes. +/// +/// # Ownership +/// +/// `ddog_ffe_evaluation_context_drop` must be called on the result value to free resources. +/// +/// # Safety +/// - `targeting_key` must be a valid C string. +/// - `attributes` must point to a valid array of valid `AttributePair` structs (can be null if +/// `attributes_count` is 0) +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_evaluation_context_new( + targeting_key: *const c_char, + attributes: *const AttributePair, + attributes_count: usize, +) -> Handle { + let targeting_key = if targeting_key.is_null() { + Str::from_static_str("") + } else { + // SAFETY: the caller must ensure that it's a valid C string + match unsafe { CStr::from_ptr(targeting_key) }.to_str() { + Ok(s) => Str::from(s), + Err(_) => Str::from_static_str(""), + } + }; + + let attributes = if attributes.is_null() { + HashMap::new() + } else { + // SAFETY: the caller must ensure that `attributes` is a valid pointer and + // `attributes_count` accurately represent the number of elements. + unsafe { std::slice::from_raw_parts(attributes, attributes_count) } + .iter() + .filter_map(|attr_pair| { + if attr_pair.name.is_null() { + return None; // Skip invalid pairs + } + + // SAFETY: the caller must ensure that it's a valid C string + let name_str = unsafe { CStr::from_ptr(attr_pair.name) }.to_str().ok()?; + + let attribute: Attribute = match attr_pair.value { + // SAFETY: the caller must ensure that it's a valid C string. + AttributeValue::String(s) => unsafe { CStr::from_ptr(s) }.to_str().ok()?.into(), + AttributeValue::Number(v) => v.into(), + AttributeValue::Boolean(v) => v.into(), + }; + + Some((Str::from(name_str), attribute)) + }) + .collect() + }; + + Handle::from(EvaluationContext::new(targeting_key, Arc::new(attributes))) +} + +/// Frees an EvaluationContext +/// +/// # Safety +/// `context` must be a valid EvaluationContext handle created by `ddog_ffe_evaluation_context_new` +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_evaluation_context_drop(context: *mut Handle) { + // SAFETY: the caller must ensure that context is a valid handle. + unsafe { Handle::free(context) }; +} diff --git a/datadog-ffe-ffi/src/handle.rs b/datadog-ffe-ffi/src/handle.rs new file mode 100644 index 000000000..6b2ec209d --- /dev/null +++ b/datadog-ffe-ffi/src/handle.rs @@ -0,0 +1,78 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +/// An opaque handle for a resource. The inner fields must not be dereferenced. +/// +/// This is similar to `libdd_common_ffi::Handle` but only allows shared access to internal +/// resource, so it's safe to share between thread or access concurrently (if the underlying type +/// is). +/// +/// # Ownership +/// +/// `Handle::free()` must be called exactly once on any created Handle. Failure to do that will +/// result in a memory leak. +#[repr(transparent)] +pub struct Handle { + inner: *mut T, +} + +// SAFETY: the box pointer is safe to move across threads as long as the underlying type is Send. +unsafe impl Send for Handle {} +// SAFETY: we only hand off shared refences, so it's Sync as long as underlying type is Sync. +unsafe impl Sync for Handle {} + +impl Handle { + /// Create a new handle to `T`. + /// + /// # Ownership + /// + /// This moves `value` to heap. + /// + /// `Handle::free()` must be called exactly once on any created Handle. Failure to do that will + /// result in a memory leak. + pub(crate) fn new(value: T) -> Handle { + Handle { + inner: Box::into_raw(Box::new(value)), + } + } + + /// Get a reference to inner value. + /// + /// # Safety + /// - `self` must be a valid handle for `T`. + #[allow(clippy::expect_used)] + pub(crate) unsafe fn as_ref(&self) -> &T { + // SAFETY: the caller must ensure that self is valid + unsafe { self.inner.as_ref() }.expect("detected use after free") + } + + /// Free this handle. This and all other copies of the handle become invalid after freeing. + /// + /// # Safety + /// - `this` must be a valid pointer to valid handle for `T`. + pub(crate) unsafe fn free(this: *mut Self) { + if this.is_null() { + return; + } + + // SAFETY: the caller must ensure that the pointer is valid. + let ptr = std::mem::replace(&mut (unsafe { &mut *this }).inner, std::ptr::null_mut()); + if ptr.is_null() { + // We try to detect double-free but it's not fool-proof. The C side might have copied + // the handle. + debug_assert!(false, "detected double-free"); + return; + } + + // SAFETY: the original value was created by Box::into_raw(). + let value = unsafe { Box::from_raw(ptr) }; + + drop(value); + } +} + +impl From for Handle { + fn from(value: T) -> Self { + Handle::new(value) + } +} diff --git a/datadog-ffe-ffi/src/lib.rs b/datadog-ffe-ffi/src/lib.rs new file mode 100644 index 000000000..072597602 --- /dev/null +++ b/datadog-ffe-ffi/src/lib.rs @@ -0,0 +1,23 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(not(test), deny(clippy::panic))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] +#![cfg_attr(not(test), deny(clippy::expect_used))] +#![cfg_attr(not(test), deny(clippy::todo))] +#![cfg_attr(not(test), deny(clippy::unimplemented))] +#![deny( + unsafe_op_in_unsafe_fn, + clippy::undocumented_unsafe_blocks, + clippy::multiple_unsafe_ops_per_block +)] + +mod assignment; +mod configuration; +mod evaluation_context; +mod handle; + +pub use assignment::*; +pub use configuration::*; +pub use evaluation_context::*; +pub use handle::*; diff --git a/datadog-ffe/src/rules_based/error.rs b/datadog-ffe/src/rules_based/error.rs index d9f3d4522..3869b24be 100644 --- a/datadog-ffe/src/rules_based/error.rs +++ b/datadog-ffe/src/rules_based/error.rs @@ -3,14 +3,14 @@ use serde::{Deserialize, Serialize}; -use crate::rules_based::{ExpectedFlagType, FlagType}; +use crate::rules_based::{ExpectedFlagType, FlagType, Str}; /// Enum representing all possible reasons that could result in evaluation returning an error or /// default assignment. /// /// Not all of these are technically "errors"—some can be expected to occur frequently (e.g., /// `FlagDisabled` or `DefaultAllocation`). -#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(thiserror::Error, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[non_exhaustive] pub enum EvaluationError { @@ -44,4 +44,7 @@ pub enum EvaluationError { /// being assigned. #[error("default allocation is matched and is serving NULL")] DefaultAllocationNull, + + #[error("internal error: {0}")] + Internal(Str), } diff --git a/tools/docker/Dockerfile.build b/tools/docker/Dockerfile.build index 329480e6e..afe92efc6 100644 --- a/tools/docker/Dockerfile.build +++ b/tools/docker/Dockerfile.build @@ -115,6 +115,7 @@ COPY "bin_tests/Cargo.toml" "bin_tests/" COPY "libdd-tinybytes/Cargo.toml" "libdd-tinybytes/" COPY "builder/Cargo.toml" "builder/" COPY "datadog-ffe/Cargo.toml" "datadog-ffe/" +COPY "datadog-ffe-ffi/Cargo.toml" "datadog-ffe-ffi/" RUN find -name "Cargo.toml" | sed -e s#Cargo.toml#src/lib.rs#g | xargs -n 1 sh -c 'mkdir -p $(dirname $1); touch $1; echo $1' create_stubs RUN echo \ bin_tests/src/bin/crashtracker_bin_test.rs \