From 11ae85df402033e264ffcf81025fc967344ee77d Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Mon, 14 Sep 2026 13:41:09 -0400 Subject: [PATCH 1/4] fix: add hstore support to value extraction and binding hstore has no well-known Postgres OID, so extract.rs's Type:: dispatch table never matched it and columns silently decoded as null (#68, #69). Dispatch on ty.name() == "hstore" instead, matching the builtin driver's extract/simple.rs pattern, and decode via tokio-postgres's native HashMap> support. Also ports the write side from tabularis#427: binding.rs rejected any JSON object bound to a non-JSON column, so editing an hstore cell failed outright. Resolves the real hstore OID per column via pg_type (extension types aren't well-known OIDs) and binds through the same HashMap shape. --- src/binding.rs | 93 +++++++++++++++++++++++++++++++++- src/binding_tests.rs | 115 ++++++++++++++++++++++++++++++++++++++++++- src/client.rs | 26 ++++++++++ src/extract.rs | 10 ++++ src/extract_tests.rs | 66 +++++++++++++++++++++++++ src/handlers/crud.rs | 19 +++++++ tests/live_db.rs | 54 ++++++++++++++++++++ 7 files changed, 381 insertions(+), 2 deletions(-) diff --git a/src/binding.rs b/src/binding.rs index dec1738..7a69c02 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -13,7 +13,8 @@ use rust_decimal::Decimal; use serde_json::Value; -use tokio_postgres::types::{ToSql, Type}; +use std::collections::HashMap; +use tokio_postgres::types::{Kind, ToSql, Type}; use uuid::Uuid; pub type PgParam = Box; @@ -44,6 +45,11 @@ pub struct BindOptions<'a> { /// the `CAST($N AS )` coercion in [`bind_pg_enum_string`]. pub enum_type: Option<&'a str>, pub allow_default: bool, + /// Real OID of the `hstore` type for this database, when `column_type` + /// is `"hstore"` — required because hstore has no well-known Postgres + /// OID and varies per installation. `None` when the column isn't + /// hstore, or resolution failed (surfaced as an error at bind time). + pub hstore_oid: Option, } const USE_DEFAULT_SENTINEL: &str = "__USE_DEFAULT__"; @@ -63,6 +69,12 @@ pub fn bind_pg_value( ) -> Result { let base_type = options.column_type.map(extract_base_type); + // hstore column — bind before the JSON/JSONB check below, since a JSON + // object destined for hstore must NOT go through the JSON ToSql path. + if options.column_type == Some("hstore") { + return bind_pg_hstore(value, placeholder_idx, options.hstore_oid); + } + // JSON/JSONB columns receiving a native JSON value (object/array/number/bool) // must bind the value's own ToSql JSON encoding — a text CAST trips an OID // mismatch for json/jsonb columns. @@ -102,6 +114,85 @@ pub fn bind_pg_value( } } +/// Binds a JSON object to an hstore column as `HashMap>`, +/// which `tokio-postgres` encodes natively via its built-in hstore `ToSql` impl. +/// Requires the real OID of the `hstore` type in this database (extension-defined, +/// not a well-known Postgres OID) so the placeholder's `Type` pins it correctly. +/// Ported from `tabularis#427`'s `bind_pg_hstore`. +fn bind_pg_hstore( + value: Value, + placeholder_idx: usize, + hstore_oid: Option, +) -> Result { + let map = match value { + Value::Null => { + return Ok(BoundValue { + sql: "NULL".to_string(), + param: None, + }); + } + Value::Object(map) => map, + + // The grid's plain-text cell editor doesn't yet know about hstore, so it + // may round-trip the value as a JSON-encoded string rather than an + // object. Accept that shape here so editing still works. + Value::String(s) => match serde_json::from_str::(&s) { + Ok(Value::Object(map)) => map, + _ => { + return Err(format!( + "hstore column requires a JSON object value, got a string that is not valid JSON: {:?}", + s + )); + } + }, + other => { + return Err(format!( + "hstore column requires a JSON object value, got {:?}", + other + )); + } + }; + + let oid = hstore_oid.ok_or_else(|| { + "Could not resolve the hstore type OID; is the hstore extension installed?".to_string() + })?; + let hmap = hstore_map_from_json_object(map)?; + let pg_type = Type::new( + "hstore".to_string(), + oid, + Kind::Simple, + "public".to_string(), + ); + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(hmap), pg_type)), + }) +} + +/// Converts a JSON object into the `HashMap>` shape that +/// `tokio-postgres` encodes natively as hstore. Every value must be a string or +/// null — hstore itself only stores text, so numbers/bools/nested objects have no +/// unambiguous representation and are rejected with a message naming the offending key. +fn hstore_map_from_json_object( + map: serde_json::Map, +) -> Result>, String> { + let mut hmap = HashMap::with_capacity(map.len()); + for (k, v) in map { + let val = match v { + Value::String(s) => Some(s), + Value::Null => None, + other => { + return Err(format!( + "hstore value for key '{}' must be a string or null, got {:?}", + k, other + )); + } + }; + hmap.insert(k, val); + } + Ok(hmap) +} + fn bind_pg_number(n: serde_json::Number, placeholder_idx: usize) -> Result { if let Some(i) = n.as_i64() { Ok(BoundValue { diff --git a/src/binding_tests.rs b/src/binding_tests.rs index bdc6314..3fe2bff 100644 --- a/src/binding_tests.rs +++ b/src/binding_tests.rs @@ -2,7 +2,7 @@ //! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod binding_tests;`. use crate::binding::{bind_pg_value, bind_pk_value, BindOptions}; -use serde_json::json; +use serde_json::{json, Value}; mod bind_pg_value_tests { use super::*; @@ -65,6 +65,7 @@ mod bind_pg_value_tests { column_type: Some("jsonb"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!({"a": 1}), 1, &options).unwrap(); assert_eq!(bound.sql, "$1"); @@ -80,6 +81,7 @@ mod bind_pg_value_tests { column_type: Some("jsonb"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("{\"a\":1}"), 1, &options).unwrap(); assert_eq!(bound.sql, "$1"); @@ -91,6 +93,7 @@ mod bind_pg_value_tests { column_type: None, enum_type: None, allow_default: true, + hstore_oid: None, }; let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); assert_eq!(bound.sql, "DEFAULT"); @@ -103,6 +106,7 @@ mod bind_pg_value_tests { column_type: None, enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); // Falls through to the plain TEXT fallback, not treated as DEFAULT. @@ -128,6 +132,7 @@ mod bind_pg_value_tests { column_type: None, enum_type: Some("\"test_schema\".\"mood\""), allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("sad"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS \"test_schema\".\"mood\")"); @@ -143,6 +148,7 @@ mod bind_pg_value_tests { column_type: None, enum_type: Some("\"public\".\"status\""), allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), 1, &options).unwrap(); @@ -155,6 +161,7 @@ mod bind_pg_value_tests { column_type: Some("boolean"), enum_type: None, allow_default: false, + hstore_oid: None, }; for truthy in ["true", "t", "yes", "y", "on", "1", "TRUE"] { let bound = bind_pg_value(json!(truthy), 1, &options).unwrap(); @@ -168,6 +175,7 @@ mod bind_pg_value_tests { column_type: Some("boolean"), enum_type: None, allow_default: false, + hstore_oid: None, }; let err = bind_pg_value(json!("maybe"), 1, &options).unwrap_err(); assert!(err.contains("boolean")); @@ -179,6 +187,7 @@ mod bind_pg_value_tests { column_type: Some("integer"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("42"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS bigint)"); @@ -190,6 +199,7 @@ mod bind_pg_value_tests { column_type: Some("integer"), enum_type: None, allow_default: false, + hstore_oid: None, }; let err = bind_pg_value(json!("not-a-number"), 1, &options).unwrap_err(); assert!(err.contains("integer")); @@ -201,6 +211,7 @@ mod bind_pg_value_tests { column_type: Some("numeric"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("12345.67"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS numeric)"); @@ -212,6 +223,7 @@ mod bind_pg_value_tests { column_type: Some("timestamp"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("2026-01-15 14:30:00"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS timestamp)"); @@ -223,6 +235,7 @@ mod bind_pg_value_tests { column_type: Some("timestamptz"), enum_type: None, allow_default: false, + hstore_oid: None, }; let bound = bind_pg_value(json!("2026-01-15 14:30:00+00"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS timestamptz)"); @@ -252,6 +265,106 @@ mod bind_pg_value_tests { assert_eq!(bound.sql, "$1"); assert!(bound.param.is_some()); } + + #[test] + fn hstore_object_bound_as_value_with_correct_type_name() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let bound = bind_pg_value(json!({"key": "value", "other": "thing"}), 1, &options).unwrap(); + + assert_eq!(bound.sql, "$1"); + let (_, pg_type) = bound.param.unwrap(); + assert_eq!(pg_type.name(), "hstore"); + assert_eq!(pg_type.oid(), 16_500); + } + + #[test] + fn hstore_object_with_null_value_bound_correctly() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let bound = bind_pg_value(json!({"key": null}), 1, &options).unwrap(); + + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn hstore_null_value_stays_sql_null() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let bound = bind_pg_value(Value::Null, 1, &options).unwrap(); + + assert_eq!(bound.sql, "NULL"); + assert!(bound.param.is_none()); + } + + #[test] + fn hstore_json_encoded_string_is_accepted_as_a_fallback() { + // The plain-text cell editor doesn't distinguish hstore from other + // types, so it may round-trip a value as a JSON-encoded string. + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let bound = bind_pg_value(json!("{\"key\": \"value\"}"), 1, &options).unwrap(); + + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn hstore_non_string_value_in_object_returns_clear_error() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let err = bind_pg_value(json!({"key": 42}), 1, &options).unwrap_err(); + + assert!(err.contains("key")); + assert!(err.contains("string or null")); + } + + #[test] + fn hstore_non_object_value_returns_clear_error() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: Some(16_500), + }; + let err = bind_pg_value(json!(42), 1, &options).unwrap_err(); + + assert!(err.contains("JSON object")); + } + + #[test] + fn hstore_object_without_resolved_oid_returns_clear_error() { + let options = BindOptions { + column_type: Some("hstore"), + enum_type: None, + allow_default: false, + hstore_oid: None, + }; + let err = bind_pg_value(json!({"key": "value"}), 1, &options).unwrap_err(); + + assert!(err.contains("hstore")); + } } mod bind_pk_value_tests { diff --git a/src/client.rs b/src/client.rs index db7bbfd..3ad117b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -226,6 +226,32 @@ pub async fn get_enum_column_types( .collect()) } +/// Resolves the real OID of the `hstore` type for a specific column, +/// confirming in the same query that the column actually is hstore. hstore +/// is an extension type (not a well-known Postgres OID), so its OID varies +/// per installation and — unlike `information_schema.columns.data_type`, +/// which reports it only as the generic "USER-DEFINED" — the concrete type +/// name has to come from the catalog directly. `None` means either the +/// extension isn't installed or the column isn't hstore. Ported from +/// `tabularis#427`'s `get_hstore_oid_for_column`. +pub async fn get_hstore_oid_for_column( + params: &ConnectionParams, + schema: &str, + table: &str, + col_name: &str, +) -> Result, String> { + let query = "SELECT a.atttypid::oid \ + FROM pg_attribute a \ + JOIN pg_class c ON c.oid = a.attrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_type t ON t.oid = a.atttypid \ + WHERE n.nspname = $1 AND c.relname = $2 AND a.attname = $3 AND t.typname = 'hstore' \ + LIMIT 1"; + + let rows = query_rows(params, query, &[&schema, &table, &col_name]).await?; + Ok(rows.first().and_then(|row| row.try_get::<_, u32>(0).ok())) +} + /// Quote a schema-qualified type name (e.g. `"public"."mood"`) so it can be /// spliced into a `CAST($N AS ...)` without becoming an injection vector. fn quote_qualified_type(type_schema: &str, type_name: &str) -> String { diff --git a/src/extract.rs b/src/extract.rs index 827acf1..64982b5 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -4,6 +4,8 @@ //! `src-tauri/src/drivers/postgres/extract/` system. Every PG type must //! produce byte-identical JSON to the builtin — the parity tests enforce this. +use std::collections::HashMap; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use rust_decimal::Decimal; use serde_json::Value as JsonValue; @@ -134,6 +136,14 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { ref t if matches!(t.kind(), Kind::Enum(_)) => { try_extract::(row, index, |v| JsonValue::String(v.0)) } + // hstore is an extension type (no well-known OID), matched by name like + // the builtin driver's `extract/simple.rs::extract_or_null`. tokio-postgres + // decodes it natively as HashMap>. + ref t if t.name() == "hstore" => { + try_extract::>>(row, index, |v| { + serde_json::to_value(v).unwrap_or(JsonValue::Null) + }) + } // For types not explicitly handled (ranges, composites, geometric, etc.), // fall back to text representation via the Display trait on the raw bytes. _ => { diff --git a/src/extract_tests.rs b/src/extract_tests.rs index 0acbf84..bca48d2 100644 --- a/src/extract_tests.rs +++ b/src/extract_tests.rs @@ -10,6 +10,7 @@ //! `extract/enum.rs::extract_or_null`. use crate::extract::{EnumLabel, Money}; +use std::collections::HashMap; use tokio_postgres::types::{FromSql, Kind, Type}; fn enum_type() -> Type { @@ -67,3 +68,68 @@ fn money_above_js_safe_integer_becomes_a_string() { serde_json::json!(above_safe.to_string()) ); } + +fn hstore_type() -> Type { + Type::new( + "hstore".to_string(), + 16_432, + Kind::Simple, + "public".to_string(), + ) +} + +/// Builds the HSTORE wire format: 4-byte big-endian entry count, then per +/// entry a 4-byte key length + key bytes, and a 4-byte value length (-1 for +/// NULL) + value bytes. Matches `postgres_protocol::types::hstore_from_sql`, +/// which `HashMap>`'s `FromSql` impl delegates to. +fn hstore_wire_bytes(entries: &[(&str, Option<&str>)]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&(entries.len() as i32).to_be_bytes()); + for (key, value) in entries { + buf.extend_from_slice(&(key.len() as i32).to_be_bytes()); + buf.extend_from_slice(key.as_bytes()); + match value { + Some(v) => { + buf.extend_from_slice(&(v.len() as i32).to_be_bytes()); + buf.extend_from_slice(v.as_bytes()); + } + None => buf.extend_from_slice(&(-1_i32).to_be_bytes()), + } + } + buf +} + +#[test] +fn hstore_type_is_matched_by_name_not_a_well_known_oid() { + // hstore is an extension type with no fixed OID (#68/#69) — dispatch in + // extract.rs must match on `ty.name()`, not a `Type::` constant. + assert_eq!(hstore_type().name(), "hstore"); + assert!(HashMap::>::accepts(&hstore_type())); +} + +#[test] +fn hstore_decodes_to_a_json_object_with_string_and_null_values() { + let bytes = hstore_wire_bytes(&[("comment", Some("This is a test")), ("s156", Some("1"))]); + let map = HashMap::>::from_sql(&hstore_type(), &bytes).unwrap(); + let json = serde_json::to_value(map).unwrap(); + assert_eq!( + json, + serde_json::json!({"comment": "This is a test", "s156": "1"}) + ); +} + +#[test] +fn hstore_null_value_decodes_to_json_null_not_a_dropped_key() { + let bytes = hstore_wire_bytes(&[("key", None)]); + let map = HashMap::>::from_sql(&hstore_type(), &bytes).unwrap(); + let json = serde_json::to_value(map).unwrap(); + assert_eq!(json, serde_json::json!({"key": null})); +} + +#[test] +fn empty_hstore_decodes_to_an_empty_json_object() { + let bytes = hstore_wire_bytes(&[]); + let map = HashMap::>::from_sql(&hstore_type(), &bytes).unwrap(); + let json = serde_json::to_value(map).unwrap(); + assert_eq!(json, serde_json::json!({})); +} diff --git a/src/handlers/crud.rs b/src/handlers/crud.rs index 2449872..b1eab17 100644 --- a/src/handlers/crud.rs +++ b/src/handlers/crud.rs @@ -68,10 +68,20 @@ async fn exec_insert( for (col_name, val) in entries { cols.push(format!("\"{}\"", col_name.replace('"', "\"\""))); let column_type = column_types.get(&col_name).map(String::as_str); + // get_column_types_map already unfolds 'USER-DEFINED' to the real + // udt_name, so hstore columns report as plain "hstore" here. + let hstore_oid = if column_type == Some("hstore") { + client::get_hstore_oid_for_column(conn_params, schema, table, &col_name) + .await + .unwrap_or(None) + } else { + None + }; let options = BindOptions { column_type, enum_type: enum_types.get(&col_name).map(String::as_str), allow_default: false, + hstore_oid, }; let bound = bind_pg_value(val, placeholder_idx, &options)?; sql_fragments.push(bound.sql); @@ -142,6 +152,15 @@ async fn exec_update( column_type: column_types.get(col_name).map(String::as_str), enum_type: enum_types.get(col_name).map(String::as_str), allow_default: true, + // get_column_types_map already unfolds 'USER-DEFINED' to the real + // udt_name, so hstore columns report as plain "hstore" here. + hstore_oid: if column_types.get(col_name).map(String::as_str) == Some("hstore") { + client::get_hstore_oid_for_column(conn_params, schema, table, col_name) + .await + .unwrap_or(None) + } else { + None + }, }; let bound = bind_pg_value(new_val, 1, &options)?; diff --git a/tests/live_db.rs b/tests/live_db.rs index c81c736..d886eff 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -281,6 +281,60 @@ fn execute_query_returns_a_real_enum_value_not_null() { ); } +#[test] +fn execute_query_returns_a_real_hstore_value_not_null() { + let mut plugin = Plugin::spawn(); + let params = conn_params(); + + // Self-contained, same shape as the enum regression test above (#7): + // hstore is an extension type, so this must not assume it's already + // installed on whatever database CI points at (#68/#69). + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "CREATE EXTENSION IF NOT EXISTS hstore" }), + ); + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "CREATE TABLE IF NOT EXISTS live_db_hstore_scratch \ + (id SERIAL PRIMARY KEY, attrs hstore)", + }), + ); + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "TRUNCATE live_db_hstore_scratch RESTART IDENTITY" }), + ); + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "INSERT INTO live_db_hstore_scratch (attrs) VALUES \ + ('\"comment\"=>\"This is a test\", \"count\"=>\"1\"'), (NULL)", + }), + ); + + let result = plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "SELECT id, attrs FROM live_db_hstore_scratch ORDER BY id", + }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0][1], + json!({"comment": "This is a test", "count": "1"}), + "a non-null hstore column must round-trip as a JSON object, not null" + ); + assert_eq!( + rows[1][1], + Value::Null, + "a genuinely-NULL hstore column must still come back as null" + ); +} + #[test] fn connection_string_connects_with_no_discrete_fields() { let mut plugin = Plugin::spawn(); From 6ed4c729cc2fd0d53ad0cbf4c4f4de022a6b41aa Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Mon, 14 Sep 2026 14:31:19 -0400 Subject: [PATCH 2/4] fix: decode arrays of custom-OID element types (enum[], hstore[], etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract.rs's dispatch only special-cased a handful of well-known array OIDs (int2/int4/int8/float4/float8/bool/text/varchar), so any array whose element type isn't one of those — enum[], hstore[], and in practice every other type without a hardcoded fast-path (numeric[], date[], json[], money[], inet[], etc.) — fell through to the generic string fallback and silently decoded to null (#72). Adds a generic 1-D array decoder (ArrayValue) that parses the array wire format directly and recurses per-element via extract_element_from_bytes, matching the builtin driver's generic Kind::Array dispatch (extract/mod.rs + extract/array.rs::try_extract_elem). Placed after the existing hardcoded array arms so their behavior is unchanged. Multi- dimensional arrays still fall back to null, consistent with this file's existing 1-D-only array handling. Verified against a live database that every previously-working scalar and array type is byte-identical to before, and that the newly-covered array types match the builtin driver's exact JSON shape for their scalar form. Filed #73 for an unrelated pre-existing bug found during this audit: hardcoded array types decode the whole column to null when any element is NULL (Vec vs Vec>), unaffected by this fix. --- src/extract.rs | 162 +++++++++++++++++++++++++++++++++++++++++++ src/extract_tests.rs | 110 ++++++++++++++++++++++++++++- tests/live_db.rs | 78 +++++++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) diff --git a/src/extract.rs b/src/extract.rs index 64982b5..5f7ec09 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -144,6 +144,19 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { serde_json::to_value(v).unwrap_or(JsonValue::Null) }) } + // Generic fallback for arrays whose element type isn't one of the + // hardcoded fast-paths above (int2/int4/int8/float4/float8/bool/ + // text/varchar) — e.g. enum[] or hstore[]. tokio_postgres's built-in + // `Vec: FromSql` requires a single concrete `T`, which can't + // express "decode each element the way `extract_value` would for a + // scalar column of that type" — so this parses the array wire + // format directly and recurses per-element, matching the builtin + // driver's generic `Kind::Array` dispatch (`extract/mod.rs` + + // `extract/array.rs::try_extract_elem`). Placed after the hardcoded + // array arms so their exact existing behavior is unaffected. + ref t if matches!(t.kind(), Kind::Array(_)) => { + try_extract::(row, index, |v| v.0) + } // For types not explicitly handled (ranges, composites, geometric, etc.), // fall back to text representation via the Display trait on the raw bytes. _ => { @@ -320,6 +333,155 @@ fn extract_simple_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue { } } +/// Wraps the raw wire format of a 1-D Postgres array whose element type +/// isn't one of the hardcoded fast-paths in `extract_value` (e.g. `enum[]` +/// or `hstore[]`). Format: 4-byte dimension count, 4-byte has-nulls flag, +/// 4-byte element type OID, then per dimension an 8-byte (length, +/// lower_bound) pair, then the elements themselves as length-prefixed +/// values (-1 length = NULL element). Decodes each element the same way +/// `extract_value` would for a scalar column of that type, matching the +/// builtin driver's generic `Kind::Array` dispatch +/// (`extract/mod.rs` + `extract/array.rs::try_extract_elem`). Multi- +/// dimensional arrays fall back to `Null`, consistent with this file's +/// existing hardcoded array arms (which only ever handle 1-D arrays). +pub(crate) struct ArrayValue(pub(crate) JsonValue); + +impl<'a> FromSql<'a> for ArrayValue { + fn from_sql( + ty: &Type, + raw: &'a [u8], + ) -> Result> { + let elem_type = match ty.kind() { + Kind::Array(t) => t.clone(), + _ => return Err("expected an array type".into()), + }; + + if raw.len() < 12 { + return Err("array buffer too short for header".into()); + } + let dimensions = i32::from_be_bytes(raw[0..4].try_into().unwrap()); + if dimensions == 0 { + return Ok(Self(JsonValue::Array(vec![]))); + } + if dimensions != 1 { + // Multi-dimensional arrays aren't modeled by this decoder — + // fall back to Null rather than misinterpreting the layout. + return Err("multi-dimensional array not supported".into()); + } + + let mut buf = &raw[12..]; + if buf.len() < 8 { + return Err("array buffer too short for dimension header".into()); + } + let len = i32::from_be_bytes(buf[0..4].try_into().unwrap()); + if len < 0 { + return Err("invalid array dimension length".into()); + } + buf = &buf[8..]; // skip length + lower_bound + + let mut elements = Vec::with_capacity(len as usize); + for _ in 0..len { + if buf.len() < 4 { + return Err("array buffer truncated before element length".into()); + } + let elem_len = i32::from_be_bytes(buf[0..4].try_into().unwrap()); + buf = &buf[4..]; + if elem_len < 0 { + elements.push(JsonValue::Null); + continue; + } + let elem_len = elem_len as usize; + if buf.len() < elem_len { + return Err("array buffer truncated before element value".into()); + } + let (elem_buf, rest) = buf.split_at(elem_len); + buf = rest; + elements.push(extract_element_from_bytes(&elem_type, elem_buf)); + } + + Ok(Self(JsonValue::Array(elements))) + } + + fn accepts(ty: &Type) -> bool { + matches!(ty.kind(), Kind::Array(_)) + } +} + +/// Decode one array element's raw bytes as JSON, covering the scalar types +/// `extract_value` handles (minus ranges/arrays, which can't appear as a +/// single array's element type here) plus enum and hstore, mirroring the +/// builtin's `try_extract_elem`. +fn extract_element_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue { + match ty { + _ if *ty == Type::BOOL => bool::from_sql(ty, buf) + .map(JsonValue::Bool) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::INT2 => i16::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::OID => u32::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::FLOAT4 => f32::from_sql(ty, buf) + .map(|v| { + serde_json::Number::from_f64(v as f64) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::FLOAT8 => f64::from_sql(ty, buf) + .map(|v| { + serde_json::Number::from_f64(v) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::TEXT + || *ty == Type::VARCHAR + || *ty == Type::BPCHAR + || *ty == Type::NAME => + { + String::from_sql(ty, buf) + .map(JsonValue::String) + .unwrap_or(JsonValue::Null) + } + _ if *ty == Type::UUID => Uuid::from_sql(ty, buf) + .map(|v| JsonValue::String(v.to_string())) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::TIMETZ => TimeTz::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::INTERVAL => Interval::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::JSON || *ty == Type::JSONB => { + serde_json::Value::from_sql(ty, buf).unwrap_or(JsonValue::Null) + } + _ if *ty == Type::BYTEA => Vec::::from_sql(ty, buf) + .map(|v| { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v); + JsonValue::String(format!("BLOB:{}:application/octet-stream:{}", v.len(), b64)) + }) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::INET || *ty == Type::CIDR => CidrOrInet::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::MACADDR => MacAddr::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if *ty == Type::MONEY => Money::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + _ if matches!(ty.kind(), Kind::Enum(_)) => EnumLabel::from_sql(ty, buf) + .map(|v| JsonValue::String(v.0)) + .unwrap_or(JsonValue::Null), + _ if ty.name() == "hstore" => HashMap::>::from_sql(ty, buf) + .map(|v| serde_json::to_value(v).unwrap_or(JsonValue::Null)) + .unwrap_or(JsonValue::Null), + _ => extract_simple_from_bytes(ty, buf), + } +} + /// PostgreSQL enum wire format is just the label's UTF-8 bytes — no length /// prefix, no OID-checked decoding. Matches /// `src-tauri/src/drivers/postgres/extract/enum.rs::extract_or_null`. diff --git a/src/extract_tests.rs b/src/extract_tests.rs index bca48d2..a5ea73f 100644 --- a/src/extract_tests.rs +++ b/src/extract_tests.rs @@ -9,7 +9,7 @@ //! nulls out), the same way the builtin driver unit-tests //! `extract/enum.rs::extract_or_null`. -use crate::extract::{EnumLabel, Money}; +use crate::extract::{ArrayValue, EnumLabel, Money}; use std::collections::HashMap; use tokio_postgres::types::{FromSql, Kind, Type}; @@ -133,3 +133,111 @@ fn empty_hstore_decodes_to_an_empty_json_object() { let json = serde_json::to_value(map).unwrap(); assert_eq!(json, serde_json::json!({})); } + +fn array_type(elem: Type) -> Type { + Type::new( + format!("_{}", elem.name()), + 16_433, + Kind::Array(elem), + "public".to_string(), + ) +} + +/// Builds the 1-D Postgres array wire format: 4-byte dimension count, +/// 4-byte has-nulls flag, 4-byte element type OID, one 8-byte +/// (length, lower_bound) dimension header, then each element as a +/// 4-byte length-prefixed value (-1 length = NULL, no bytes follow). +/// Matches `postgres_protocol::types::array_from_sql`, which +/// `ArrayValue::from_sql` parses directly (#72). +fn array_wire_bytes(element_oid: u32, elements: &[Option<&[u8]>]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&1_i32.to_be_bytes()); // dimensions + buf.extend_from_slice(&0_i32.to_be_bytes()); // has_nulls (unused by our decoder) + buf.extend_from_slice(&element_oid.to_be_bytes()); + buf.extend_from_slice(&(elements.len() as i32).to_be_bytes()); // dim length + buf.extend_from_slice(&1_i32.to_be_bytes()); // lower_bound + for elem in elements { + match elem { + Some(bytes) => { + buf.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + buf.extend_from_slice(bytes); + } + None => buf.extend_from_slice(&(-1_i32).to_be_bytes()), + } + } + buf +} + +fn empty_array_wire_bytes() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&0_i32.to_be_bytes()); // dimensions = 0 -> empty + buf.extend_from_slice(&0_i32.to_be_bytes()); + buf.extend_from_slice(&Type::INT4.oid().to_be_bytes()); + buf +} + +#[test] +fn array_value_accepts_only_array_kinds() { + assert!(ArrayValue::accepts(&array_type(Type::INT4))); + assert!(!ArrayValue::accepts(&Type::INT4)); + assert!(!ArrayValue::accepts(&enum_type())); +} + +#[test] +fn enum_array_decodes_each_element_as_its_label_string() { + // enum[] has no hardcoded fast-path in extract.rs (no well-known OID), + // so it must go through the generic per-element decoder (#72) rather + // than tokio_postgres's Vec: FromSql (which requires one concrete T). + let ty = array_type(enum_type()); + let bytes = array_wire_bytes(enum_type().oid(), &[Some(b"happy"), Some(b"sad")]); + let array = ArrayValue::from_sql(&ty, &bytes).unwrap(); + assert_eq!(array.0, serde_json::json!(["happy", "sad"])); +} + +#[test] +fn enum_array_null_element_becomes_json_null_not_a_dropped_slot() { + let ty = array_type(enum_type()); + let bytes = array_wire_bytes(enum_type().oid(), &[Some(b"happy"), None]); + let array = ArrayValue::from_sql(&ty, &bytes).unwrap(); + assert_eq!(array.0, serde_json::json!(["happy", null])); +} + +#[test] +fn hstore_array_decodes_each_element_as_a_json_object() { + let ty = array_type(hstore_type()); + let a = hstore_wire_bytes(&[("a", Some("1"))]); + let b = hstore_wire_bytes(&[("b", Some("2"))]); + let bytes = array_wire_bytes(hstore_type().oid(), &[Some(&a), Some(&b)]); + let array = ArrayValue::from_sql(&ty, &bytes).unwrap(); + assert_eq!(array.0, serde_json::json!([{"a": "1"}, {"b": "2"}])); +} + +#[test] +fn numeric_array_decodes_via_the_extract_simple_from_bytes_fallback() { + // INT8 isn't one of extract_element_from_bytes's explicit arms, so this + // exercises its fallback to extract_simple_from_bytes — proving that + // shared helper (already covered by range tests) also drives + // array-element decoding correctly for types beyond enum/hstore. + let ty = array_type(Type::INT8); + let elem_bytes = 42_i64.to_be_bytes(); + let bytes = array_wire_bytes(Type::INT8.oid(), &[Some(&elem_bytes)]); + let array = ArrayValue::from_sql(&ty, &bytes).unwrap(); + assert_eq!(array.0, serde_json::json!([42])); +} + +#[test] +fn empty_array_decodes_to_an_empty_json_array() { + let ty = array_type(Type::INT4); + let array = ArrayValue::from_sql(&ty, &empty_array_wire_bytes()).unwrap(); + assert_eq!(array.0, serde_json::json!([])); +} + +#[test] +fn multi_dimensional_array_is_rejected_rather_than_misparsed() { + let ty = array_type(Type::INT4); + let mut buf = Vec::new(); + buf.extend_from_slice(&2_i32.to_be_bytes()); // dimensions = 2 + buf.extend_from_slice(&0_i32.to_be_bytes()); + buf.extend_from_slice(&Type::INT4.oid().to_be_bytes()); + assert!(ArrayValue::from_sql(&ty, &buf).is_err()); +} diff --git a/tests/live_db.rs b/tests/live_db.rs index d886eff..8f29b96 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -335,6 +335,84 @@ fn execute_query_returns_a_real_hstore_value_not_null() { ); } +#[test] +fn execute_query_returns_real_array_values_for_custom_oid_element_types() { + let mut plugin = Plugin::spawn(); + let params = conn_params(); + + // Self-contained, same shape as the enum/hstore regression tests above + // (#7, #68/#69). Arrays of a custom-OID element type (enum[], hstore[]) + // have no hardcoded fast-path in extract.rs — before #72's fix they fell + // through to the generic string fallback and came back as null. + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "DO $$ BEGIN \ + CREATE TYPE live_db_test_array_mood AS ENUM ('happy', 'sad'); \ + EXCEPTION WHEN duplicate_object THEN null; END $$", + }), + ); + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "CREATE EXTENSION IF NOT EXISTS hstore" }), + ); + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "CREATE TABLE IF NOT EXISTS live_db_array_scratch \ + (id SERIAL PRIMARY KEY, \ + moods live_db_test_array_mood[], \ + attrs hstore[])", + }), + ); + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "TRUNCATE live_db_array_scratch RESTART IDENTITY" }), + ); + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "INSERT INTO live_db_array_scratch (moods, attrs) VALUES \ + (ARRAY['happy'::live_db_test_array_mood, 'sad'::live_db_test_array_mood], \ + ARRAY['a=>1'::hstore, 'b=>2'::hstore]), \ + (NULL, NULL)", + }), + ); + + let result = plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "SELECT id, moods, attrs FROM live_db_array_scratch ORDER BY id", + }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0][1], + json!(["happy", "sad"]), + "an enum[] column must round-trip as a JSON array of label strings, not null" + ); + assert_eq!( + rows[0][2], + json!([{"a": "1"}, {"b": "2"}]), + "an hstore[] column must round-trip as a JSON array of objects, not null" + ); + assert_eq!( + rows[1][1], + Value::Null, + "a genuinely-NULL array column must still come back as null" + ); + assert_eq!( + rows[1][2], + Value::Null, + "a genuinely-NULL array column must still come back as null" + ); +} + #[test] fn connection_string_connects_with_no_discrete_fields() { let mut plugin = Plugin::spawn(); From 4c8a5aa8a328c7dbac3e71efe46d6d22bec9c8df Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Tue, 15 Sep 2026 07:42:47 -0400 Subject: [PATCH 3/4] build(deps): bump rustls to 0.23.45, fixing RUSTSEC-2026-0285 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rustls 0.23.43 incorrectly accepted TLS 1.3 handshake messages sent at the wrong encryption level when packed into the same record as a key-changing message (CVE-2025-61730) — fixed upstream in 0.23.45. CI's cargo-audit gate started failing on this PR once the advisory was published, unrelated to this branch's actual changes. --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcb89f1..8f0a6c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,9 +934,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -997,9 +997,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From 60840beab324ac339be1e4668f472360846aeef8 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Tue, 15 Sep 2026 07:54:45 -0400 Subject: [PATCH 4/4] fix: don't pre-allocate array element Vec from an untrusted wire length ArrayValue::from_sql called Vec::with_capacity(len) using the array header's claimed element count before validating the buffer actually contains that many elements. A truncated or corrupted array value can claim up to i32::MAX elements while carrying far fewer bytes, which would attempt a multi-gigabyte allocation on the first decode attempt, before the per-element truncation check ever runs. Vec::new() grows by amortized doubling as elements are actually read, so the allocation stays proportional to what's genuinely present in the buffer. Added a regression test with a claimed i32::MAX-element array and no element bytes following it. --- src/extract.rs | 9 ++++++++- src/extract_tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/extract.rs b/src/extract.rs index 5f7ec09..caa1e83 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -379,7 +379,14 @@ impl<'a> FromSql<'a> for ArrayValue { } buf = &buf[8..]; // skip length + lower_bound - let mut elements = Vec::with_capacity(len as usize); + // Don't pre-allocate based on the claimed length: it's untrusted + // (comes straight off the wire) and a truncated/malformed buffer + // could claim up to i32::MAX elements while containing far fewer + // bytes, turning a single bad row into a multi-gigabyte allocation + // before the truncation check below ever runs. `Vec::new()` grows + // by amortized doubling as elements are actually read, so the + // allocation stays proportional to what's really in the buffer. + let mut elements = Vec::new(); for _ in 0..len { if buf.len() < 4 { return Err("array buffer truncated before element length".into()); diff --git a/src/extract_tests.rs b/src/extract_tests.rs index a5ea73f..7d861e0 100644 --- a/src/extract_tests.rs +++ b/src/extract_tests.rs @@ -241,3 +241,21 @@ fn multi_dimensional_array_is_rejected_rather_than_misparsed() { buf.extend_from_slice(&Type::INT4.oid().to_be_bytes()); assert!(ArrayValue::from_sql(&ty, &buf).is_err()); } + +#[test] +fn huge_claimed_length_with_truncated_buffer_does_not_attempt_unbounded_allocation() { + // A malformed/truncated array buffer could claim a huge element count + // (e.g. i32::MAX) while actually containing far fewer bytes. This must + // error out cheaply rather than pre-allocating a Vec sized to the + // claimed (attacker/corruption-controlled) length. + let ty = array_type(Type::INT4); + let mut buf = Vec::new(); + buf.extend_from_slice(&1_i32.to_be_bytes()); // dimensions = 1 + buf.extend_from_slice(&0_i32.to_be_bytes()); + buf.extend_from_slice(&Type::INT4.oid().to_be_bytes()); + buf.extend_from_slice(&i32::MAX.to_be_bytes()); // claimed length: ~2.1 billion + buf.extend_from_slice(&1_i32.to_be_bytes()); // lower_bound + // No element bytes follow — buffer is truncated relative to the claim. + let result = ArrayValue::from_sql(&ty, &buf); + assert!(result.is_err()); +}