Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions include/leech2.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#ifndef __LEECH2_H__
#define __LEECH2_H__

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

Expand All @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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);

/**
Expand Down
79 changes: 70 additions & 9 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -291,19 +293,77 @@ 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<Cell> {
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(
config: *const config::Config,
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 {
Expand All @@ -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;
}
Expand All @@ -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;
};

Expand All @@ -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;
}
Expand Down
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -267,8 +268,11 @@ fn cmd_patch_sql(config: &Config) -> Result<String> {
}

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)?;
Expand Down
54 changes: 22 additions & 32 deletions src/patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -408,7 +408,6 @@ impl Patch {
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::Cell;

fn empty_patch() -> Patch {
Patch {
Expand All @@ -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"));
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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!(
Expand All @@ -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");
Expand All @@ -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());
}
}
9 changes: 6 additions & 3 deletions tests/accept_injected_fields.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
Loading