diff --git a/include/leech2.h b/include/leech2.h index 8ce936c..84f82e1 100644 --- a/include/leech2.h +++ b/include/leech2.h @@ -9,6 +9,7 @@ #ifndef __LEECH2_H__ #define __LEECH2_H__ +#include #include #include @@ -33,6 +34,26 @@ typedef enum { LCH_LOG_TRACE = 5, } lch_log_level_t; +typedef enum { + LCH_VALUE_NULL = 0, + LCH_VALUE_TEXT = 1, + LCH_VALUE_NUMBER = 2, + LCH_VALUE_BOOLEAN = 3, +} lch_kind_t; + +typedef struct { + lch_kind_t kind; + union { + /* Valid when kind == LCH_VALUE_TEXT. Null-terminated, must not be NULL; + * use LCH_VALUE_NULL to represent a null value. */ + const char *text; + /* Valid when kind == LCH_VALUE_NUMBER. Must be finite (not NaN/Inf). */ + double number; + /* Valid when kind == LCH_VALUE_BOOLEAN. */ + bool boolean; + }; +} lch_cell_t; + /** * Callback type for receiving log messages. * @@ -161,16 +182,17 @@ extern int lch_patch_to_sql(const lch_config_t *cfg, const uint8_t *buf, * Inject a field into an encoded patch. * * Decodes the patch in @p in_buf, adds or overwrites an injected field with - * the given @p name, @p value, and @p kind, and encodes the result into a - * new caller-owned buffer written to @p out_buf and @p out_len. The input - * buffer is not modified; the caller manages its lifetime independently. + * the given @p name and @p cell, and encodes the result into a new + * caller-owned buffer written to @p out_buf and @p out_len. The input buffer + * is not modified; the caller manages its lifetime independently. * - * @p kind controls how @p value is formatted as a SQL literal. It must be - * one of "TEXT" (single-quoted), "NUMBER" (numeric, unquoted), or "BOOLEAN" - * (emitted as TRUE/FALSE). Matching is case-insensitive. + * The kind tag on @p cell determines how the value is formatted as a SQL + * literal (TEXT becomes single-quoted, NUMBER is emitted as a numeric + * literal, BOOLEAN is emitted as TRUE/FALSE). LCH_VALUE_NULL is not + * accepted. * - * If a field with the same @p name is already present on the patch — whether - * from static configuration or a prior injection — both its value and kind + * If a field with the same @p name is already present on the patch -- whether + * from static configuration or a prior injection -- both its value and kind * are replaced. * * The buffer written to @p out_buf must eventually be freed with @@ -180,15 +202,14 @@ extern int lch_patch_to_sql(const lch_config_t *cfg, const uint8_t *buf, * @param in_buf Pointer to the encoded input patch (must not be NULL). * @param in_len Length of @p in_buf in bytes. * @param name Column name (non-empty, null-terminated). - * @param value Value to inject (null-terminated). - * @param kind "TEXT", "NUMBER", or "BOOLEAN" (null-terminated). + * @param cell Typed value to inject (must not be NULL). * @param[out] out_buf Receives a pointer to the encoded output patch. * @param[out] out_len Receives the length of @p out_buf in bytes. * @return LCH_SUCCESS on success, LCH_FAILURE on error. */ extern int lch_patch_inject(const lch_config_t *cfg, const uint8_t *in_buf, - size_t in_len, const char *name, const char *value, - const char *kind, uint8_t **out_buf, + size_t in_len, const char *name, + const lch_cell_t *cell, uint8_t **out_buf, size_t *out_len); /** diff --git a/src/lib.rs b/src/lib.rs index 35aee15..5d8c3f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ -use std::ffi::{CStr, CString, c_char, c_void}; +use std::ffi::{CStr, CString, c_char, c_int, c_void}; use std::path::PathBuf; +use crate::cell::Cell; + pub mod block; pub mod cell; pub mod config; @@ -291,10 +293,69 @@ pub unsafe extern "C" fn lch_patch_to_sql( }) } +const LCH_VALUE_NULL: c_int = 0; +const LCH_VALUE_TEXT: c_int = 1; +const LCH_VALUE_NUMBER: c_int = 2; +const LCH_VALUE_BOOLEAN: c_int = 3; + +/// ABI-compatible mirror of `lch_cell_t` from `leech2.h`. Only used to type +/// FFI parameters; the Rust side reads it via [`cell_from_ffi`]. +#[repr(C)] +pub union LchCellPayload { + text: *const c_char, + number: f64, + boolean: bool, +} + +#[repr(C)] +pub struct LchCell { + kind: c_int, + payload: LchCellPayload, +} + +/// Convert an FFI `lch_cell_t` into a domain [`Cell`]. Validates the kind +/// tag, rejects non-finite numbers, and (for TEXT) verifies the pointer is +/// non-null and UTF-8. Logs an error and returns `None` on failure; callers +/// translate `None` into the function's failure sentinel. +/// +/// # Safety +/// When `cell.kind == LCH_VALUE_TEXT`, `cell.payload.text` must point to a +/// valid, null-terminated C string. A null pointer is rejected with an +/// error; use `LCH_VALUE_NULL` to represent a null value. +unsafe fn cell_from_ffi(fn_name: &str, cell: &LchCell) -> Option { + match cell.kind { + LCH_VALUE_NULL => Some(Cell::Null), + LCH_VALUE_TEXT => { + let ptr = unsafe { cell.payload.text }; + let s = unsafe { cstr_arg(fn_name, "cell.text", ptr) }?; + Some(Cell::Text(s.to_string())) + } + LCH_VALUE_NUMBER => match Cell::number(unsafe { cell.payload.number }) { + Ok(cell) => Some(cell), + Err(e) => { + log::error!("{}(): Bad argument: cell.number: {:#}", fn_name, e); + None + } + }, + LCH_VALUE_BOOLEAN => Some(Cell::Boolean(unsafe { cell.payload.boolean })), + other => { + log::error!( + "{}(): Bad argument: cell.kind: unknown kind tag {}", + fn_name, + other + ); + None + } + } +} + /// # Safety /// `config` must be a valid, non-null pointer returned by `lch_init`. /// `in_buf` must be a valid, non-null pointer to `in_len` bytes. -/// `name`, `value`, and `kind` must be valid, non-null, null-terminated C strings. +/// `name` must be a valid, non-null, null-terminated C string. +/// `cell` must be a valid, non-null pointer to an `lch_cell_t`; if its +/// kind is TEXT, the embedded text pointer must be a valid, null-terminated +/// C string. /// `out_buf` and `out_len` must be valid, non-null pointers. #[unsafe(no_mangle)] pub unsafe extern "C" fn lch_patch_inject( @@ -302,8 +363,7 @@ pub unsafe extern "C" fn lch_patch_inject( in_buf: *const u8, in_len: usize, name: *const c_char, - value: *const c_char, - kind: *const c_char, + cell: *const LchCell, out_buf: *mut *mut u8, out_len: *mut usize, ) -> i32 { @@ -314,6 +374,9 @@ pub unsafe extern "C" fn lch_patch_inject( if null_arg("lch_patch_inject", "in_buf", in_buf) { return FAILURE; } + if null_arg("lch_patch_inject", "cell", cell) { + return FAILURE; + } if null_arg("lch_patch_inject", "out_buf", out_buf) { return FAILURE; } @@ -324,10 +387,8 @@ pub unsafe extern "C" fn lch_patch_inject( let Some(name) = (unsafe { cstr_arg("lch_patch_inject", "name", name) }) else { return FAILURE; }; - let Some(value) = (unsafe { cstr_arg("lch_patch_inject", "value", value) }) else { - return FAILURE; - }; - let Some(kind) = (unsafe { cstr_arg("lch_patch_inject", "kind", kind) }) else { + + let Some(cell) = (unsafe { cell_from_ffi("lch_patch_inject", &*cell) }) else { return FAILURE; }; @@ -342,7 +403,7 @@ pub unsafe extern "C" fn lch_patch_inject( } }; - if let Err(e) = patch.inject_field(name, value, kind) { + if let Err(e) = patch.inject_field(name, cell) { log::error!("lch_patch_inject(): {:#}", e); return FAILURE; } diff --git a/src/main.rs b/src/main.rs index fb4dc96..436600d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::process::{Command as ProcessCommand, ExitCode, Stdio}; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use leech2::block::Block; +use leech2::cell::{Kind, parse_typed_cell}; use leech2::config::Config; use leech2::utils::{GENESIS_HASH, format_timestamp}; @@ -267,8 +268,11 @@ fn cmd_patch_sql(config: &Config) -> Result { } fn cmd_patch_inject(config: &Config, name: &str, value: &str, kind: &str) -> Result<()> { + let kind = Kind::from_config(kind).context("invalid kind")?; + let cell = parse_typed_cell(value, kind).context("invalid value")?; + let mut patch = load_patch(config)?; - patch.inject_field(name, value, kind)?; + patch.inject_field(name, cell)?; let encoded = leech2::wire::encode_patch(config, &patch)?; leech2::storage::store(&config.work_dir, PATCH_FILE, &encoded)?; diff --git a/src/patch.rs b/src/patch.rs index 845b79e..73356d8 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -9,7 +9,7 @@ use prost::Message; use prost_types::Timestamp; use crate::block::Block; -use crate::cell::{Kind, parse_typed_cell}; +use crate::cell::{Cell, parse_typed_cell}; use crate::config::{Config, InjectedFieldConfig}; use crate::delta::Delta; use crate::head; @@ -367,19 +367,19 @@ impl Patch { } /// Add or overwrite an injected field on this patch. Validates that the - /// name is non-empty and the kind is one of TEXT, NUMBER, or BOOLEAN. - /// If a field with the same name already exists (whether from static - /// config or a previous inject_field call), its value is replaced; a - /// warning is logged when the replacement actually differs from the - /// existing value. - pub fn inject_field(&mut self, name: &str, value: &str, kind: &str) -> Result<()> { + /// name is non-empty and the value is not [`Cell::Null`]. If a field + /// with the same name already exists (whether from static config or a + /// previous inject_field call), its value is replaced; a warning is + /// logged when the replacement actually differs from the existing value. + pub fn inject_field(&mut self, name: &str, value: Cell) -> Result<()> { if name.is_empty() { bail!("inject_field: name must not be empty"); } + if matches!(value, Cell::Null) { + bail!("inject_field: NULL values are not supported"); + } - let kind = Kind::from_config(kind).context("inject_field: invalid kind")?; - let parsed = parse_typed_cell(value, kind).context("inject_field: invalid value")?; - let new_value: crate::proto::cell::Cell = parsed.into(); + let new_value: crate::proto::cell::Cell = value.into(); if let Some(existing) = self.injected_fields.iter_mut().find(|f| f.name == name) { if existing.value.as_ref() != Some(&new_value) { @@ -408,7 +408,6 @@ impl Patch { #[cfg(test)] mod tests { use super::*; - use crate::cell::Cell; fn empty_patch() -> Patch { Patch { @@ -428,7 +427,7 @@ mod tests { #[test] fn test_inject_field_add_text() { let mut patch = empty_patch(); - patch.inject_field("hostkey", "abc", "TEXT").unwrap(); + patch.inject_field("hostkey", Cell::from("abc")).unwrap(); assert_eq!(patch.injected_fields.len(), 1); assert_eq!(patch.injected_fields[0].name, "hostkey"); assert_eq!(injected_value(&patch.injected_fields[0]), Cell::from("abc")); @@ -437,7 +436,7 @@ mod tests { #[test] fn test_inject_field_add_number() { let mut patch = empty_patch(); - patch.inject_field("count", "42", "NUMBER").unwrap(); + patch.inject_field("count", Cell::Number(42.0)).unwrap(); assert_eq!( injected_value(&patch.injected_fields[0]), Cell::Number(42.0) @@ -447,7 +446,7 @@ mod tests { #[test] fn test_inject_field_add_boolean() { let mut patch = empty_patch(); - patch.inject_field("enabled", "true", "BOOLEAN").unwrap(); + patch.inject_field("enabled", Cell::Boolean(true)).unwrap(); assert_eq!( injected_value(&patch.injected_fields[0]), Cell::Boolean(true) @@ -461,7 +460,7 @@ mod tests { name: "host".to_string(), value: Some(Cell::Number(1.0).into()), }); - patch.inject_field("host", "new-value", "TEXT").unwrap(); + patch.inject_field("host", Cell::from("new-value")).unwrap(); assert_eq!(patch.injected_fields.len(), 1); assert_eq!(patch.injected_fields[0].name, "host"); assert_eq!( @@ -473,8 +472,8 @@ mod tests { #[test] fn test_inject_field_multiple_distinct_names_append() { let mut patch = empty_patch(); - patch.inject_field("a", "1", "TEXT").unwrap(); - patch.inject_field("b", "2", "TEXT").unwrap(); + patch.inject_field("a", Cell::from("1")).unwrap(); + patch.inject_field("b", Cell::from("2")).unwrap(); assert_eq!(patch.injected_fields.len(), 2); assert_eq!(patch.injected_fields[0].name, "a"); assert_eq!(patch.injected_fields[1].name, "b"); @@ -483,30 +482,21 @@ mod tests { #[test] fn test_inject_field_rejects_empty_name() { let mut patch = empty_patch(); - let err = patch.inject_field("", "value", "TEXT").unwrap_err(); + let err = patch.inject_field("", Cell::from("value")).unwrap_err(); assert!(err.to_string().contains("name must not be empty")); } #[test] - fn test_inject_field_rejects_invalid_type() { - let mut patch = empty_patch(); - let err = patch.inject_field("foo", "bar", "BOGUS").unwrap_err(); - assert!(err.to_string().contains("invalid kind")); - } - - #[test] - fn test_inject_field_rejects_invalid_value() { + fn test_inject_field_rejects_null() { let mut patch = empty_patch(); - let err = patch - .inject_field("count", "not_a_number", "NUMBER") - .unwrap_err(); - assert!(err.to_string().contains("invalid value")); + let err = patch.inject_field("foo", Cell::Null).unwrap_err(); + assert!(err.to_string().contains("NULL values are not supported")); } #[test] - fn test_inject_field_invalid_type_does_not_mutate() { + fn test_inject_field_null_does_not_mutate() { let mut patch = empty_patch(); - let _ = patch.inject_field("foo", "bar", "BOGUS"); + let _ = patch.inject_field("foo", Cell::Null); assert!(patch.injected_fields.is_empty()); } } diff --git a/tests/accept_injected_fields.rs b/tests/accept_injected_fields.rs index 6f06f30..1b33ac8 100644 --- a/tests/accept_injected_fields.rs +++ b/tests/accept_injected_fields.rs @@ -1,6 +1,7 @@ mod common; use leech2::block::Block; +use leech2::cell::Cell; use leech2::config::Config; use leech2::patch::Patch; use leech2::sql; @@ -275,7 +276,7 @@ fields = [ Block::create(&config).unwrap(); let mut patch = Patch::create(&config, GENESIS_HASH).unwrap(); - patch.inject_field("hostkey", "abc123", "TEXT").unwrap(); + patch.inject_field("hostkey", Cell::from("abc123")).unwrap(); let sql = sql::patch_to_sql(&config, &patch).unwrap().unwrap(); @@ -323,7 +324,7 @@ fields = [ Block::create(&config).unwrap(); let mut patch = Patch::create(&config, GENESIS_HASH).unwrap(); - patch.inject_field("hub_id", "hub-1", "TEXT").unwrap(); + patch.inject_field("hub_id", Cell::from("hub-1")).unwrap(); let sql = sql::patch_to_sql(&config, &patch).unwrap().unwrap(); @@ -370,7 +371,9 @@ fields = [ Block::create(&config).unwrap(); let mut patch = Patch::create(&config, GENESIS_HASH).unwrap(); - patch.inject_field("host", "hub-verified", "TEXT").unwrap(); + patch + .inject_field("host", Cell::from("hub-verified")) + .unwrap(); let sql = sql::patch_to_sql(&config, &patch).unwrap().unwrap(); diff --git a/tests/round_trip.rs b/tests/round_trip.rs index 9c29ae6..45858d4 100644 --- a/tests/round_trip.rs +++ b/tests/round_trip.rs @@ -23,6 +23,7 @@ use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use leech2::block::Block; +use leech2::cell::Cell; use leech2::config::Config; use leech2::patch::Patch; use leech2::sql::{self, quote_identifier}; @@ -581,7 +582,9 @@ fn run_round_for_agent( }, ); - patch.inject_field("host", &run.name, "TEXT").unwrap(); + patch + .inject_field("host", Cell::from(run.name.as_str())) + .unwrap(); let hub_sql = sql::patch_to_sql(&config, &patch).unwrap(); ship_and_verify(hub, hub_sql.as_deref(), &run.agent, Some(&run.name)).unwrap_or_else(|e| { panic!( diff --git a/tests/test_c_ffi.c b/tests/test_c_ffi.c index 0f2dba2..027a151 100644 --- a/tests/test_c_ffi.c +++ b/tests/test_c_ffi.c @@ -70,8 +70,9 @@ int main(int argc, char *argv[]) { uint8_t *injected_buf = NULL; size_t injected_len = 0; - ret = lch_patch_inject(cfg, buf, len, "hostkey", "abc123", "TEXT", - &injected_buf, &injected_len); + lch_cell_t hostkey_cell = {.kind = LCH_VALUE_TEXT, .text = "abc123"}; + ret = lch_patch_inject(cfg, buf, len, "hostkey", &hostkey_cell, &injected_buf, + &injected_len); if (ret == LCH_FAILURE) { fprintf(stderr, "lch_patch_inject failed\n"); lch_patch_free(buf, len);