From d76abc357cb10e6b5639fc5f6ad231f91c94384b Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:38:39 -0700 Subject: [PATCH] Fix local Row::get_value truncation of NUL bytes and UTF-8 panic Convert owned TEXT values through the length-aware get_ref path instead of a NUL-terminated CStr. Embedded NULs are preserved, and malformed UTF-8 now returns Error::InvalidColumnType instead of panicking. Fixes #2285 --- libsql/src/local/rows.rs | 13 +++++- libsql/tests/get_value_text.rs | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 libsql/tests/get_value_text.rs diff --git a/libsql/src/local/rows.rs b/libsql/src/local/rows.rs index 4d4e622c75..7c7e23ff25 100644 --- a/libsql/src/local/rows.rs +++ b/libsql/src/local/rows.rs @@ -127,8 +127,17 @@ impl Row { } pub fn get_value(&self, idx: i32) -> Result { - let val = self.stmt.inner.column_value(idx); - ::from_sql(val) + // Use the exact byte length, preserve NULs, + // and reject invalid UTF-8 instead of truncating or panicking. + match self.get_ref(idx)? { + ValueRef::Null => Ok(Value::Null), + ValueRef::Integer(n) => Ok(Value::Integer(n)), + ValueRef::Real(n) => Ok(Value::Real(n)), + ValueRef::Text(bytes) => std::str::from_utf8(bytes) + .map(|text| Value::Text(text.to_owned())) + .map_err(|_| Error::InvalidColumnType), + ValueRef::Blob(bytes) => Ok(Value::Blob(bytes.to_vec())), + } } pub fn column_type(&self, idx: i32) -> Result { diff --git a/libsql/tests/get_value_text.rs b/libsql/tests/get_value_text.rs new file mode 100644 index 0000000000..3a041c3d98 --- /dev/null +++ b/libsql/tests/get_value_text.rs @@ -0,0 +1,79 @@ +#![allow(deprecated)] + +// Regression tests for tursodatabase/libsql#2285: +// local Row::get_value must preserve embedded NULs in TEXT and must return +// a decoding error (not panic) for malformed UTF-8. +use libsql::{Database, Error, Value}; + +async fn setup() -> libsql::Connection { + let db = Database::open(":memory:").unwrap(); + db.connect().unwrap() +} + +#[tokio::test] +async fn get_value_preserves_embedded_nul_in_text() { + let conn = setup().await; + + let mut rows = conn + .query("SELECT ?1", [Value::Text("a\0b".to_string())]) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get_value(0).unwrap(), Value::Text("a\0b".to_string())); + drop(row); + drop(rows); + + // distinct keys with NULs stay distinct through the owned conversion + let mut rows = conn + .query("SELECT ?1", [Value::Text("a".to_string())]) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get_value(0).unwrap(), Value::Text("a".to_string())); +} + +#[tokio::test] +async fn get_value_rejects_malformed_utf8_without_panicking() { + let conn = setup().await; + + let mut rows = conn + .query("SELECT CAST(x'80' AS TEXT)", ()) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + let err = row.get_value(0).unwrap_err(); + assert!( + matches!(err, Error::InvalidColumnType), + "unexpected error: {err:?}" + ); + drop(row); + drop(rows); + + // connection is still usable afterwards + let mut rows = conn.query("SELECT 'ok'", ()).await.unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get_value(0).unwrap(), Value::Text("ok".into())); +} + +#[tokio::test] +async fn get_value_handles_empty_text_unicode_and_other_types() { + let conn = setup().await; + + let mut rows = conn + .query("SELECT '', 'héllo 🌍', 42, 1.5, x'deadbeef', NULL", ()) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get_value(0).unwrap(), Value::Text("".into())); + assert_eq!( + row.get_value(1).unwrap(), + Value::Text("héllo 🌍".to_string()) + ); + assert_eq!(row.get_value(2).unwrap(), Value::Integer(42)); + assert_eq!(row.get_value(3).unwrap(), Value::Real(1.5)); + assert_eq!( + row.get_value(4).unwrap(), + Value::Blob(vec![0xde, 0xad, 0xbe, 0xef]) + ); + assert_eq!(row.get_value(5).unwrap(), Value::Null); +}