From 4ac44811bcc744c328e846d30586967e1e46dad0 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Wed, 16 Sep 2026 15:54:49 -0400 Subject: [PATCH] fix: cap internal planner-statistics blob types at the preview size (#106) extract.rs's binary_blob_wrapper! macro -- used for PgMcvList, PgDependencies, PgNdistinct, and the two BRIN summary types -- had its own independently-drifted copy of the untruncated BLOB: encoding that #87 fixed for ordinary BYTEA columns. Its doc comment even claimed this matched the builtin, but the builtin's equivalent macro (binary_wrapper! in extract/advanced_types.rs) actually calls the builtin's own encode_blob (the truncated encoder), not an untruncated one -- so the plugin had drifted into a second, separate divergence. Switched the macro to use the shared crate::utils::blob::encode_blob (added in #87) instead of inlining its own base64 encoding, and corrected the stale doc comment. TDD: added internal_statistics_blob_types_truncate_a_large_buffer_to_the_preview_cap in extract_tests.rs (a synthetic 20 KB buffer, since real pg_statistic_ext_data values are typically well under 10 KB in practice -- confirmed live against a real CREATE STATISTICS run, which produced a 78-byte value, far too small to exercise truncation). Confirmed the new test fails against the pre-fix code (20480 vs expected 10240) before the fix, passes after. The existing internal_statistics_blob_types_decode_and_accept_correctly test (small, live-captured buffers) continues to pass unchanged, since those values are far under the cap. Verified live against a real PostgreSQL instance: a genuine pg_statistic_ext_data.stxdndistinct value (from an actual CREATE STATISTICS ... ON a, b, c run) still decodes correctly through the fixed macro with no error. --- src/extract.rs | 21 ++++++++------------- src/extract_tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/extract.rs b/src/extract.rs index ad3e36d..94ed975 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -2354,13 +2354,13 @@ impl From for JsonValue { } /// A wire format that's just an opaque byte blob with no meaningful text -/// representation — encoded the same way this plugin's existing `BYTEA` -/// arm does (see `extract_simple_kind`'s `Type::BYTEA` arm): the full -/// base64 payload with a hardcoded `application/octet-stream` MIME type, -/// no truncation. Matches the builtin's `binary_wrapper!` macro -/// (`extract/advanced_types.rs`), used for internal planner-statistics -/// types too rarely queried directly to be worth a real text -/// representation. +/// representation — encoded via the same truncated-preview `encode_blob` +/// this plugin's `Type::BYTEA` arm uses (see `crate::utils::blob`). +/// Matches the builtin's `binary_wrapper!` macro (`extract/advanced_types.rs`), +/// which calls the builtin's own `encode_blob` for the same reason: these +/// are internal planner-statistics types too rarely queried directly to be +/// worth a real text representation, but a large one should still be capped +/// like any other BYTEA read. macro_rules! binary_blob_wrapper { ($name:ident, $pg_type:ident) => { pub(crate) struct $name(Vec); @@ -2380,12 +2380,7 @@ macro_rules! binary_blob_wrapper { impl From<$name> for JsonValue { fn from(v: $name) -> Self { - let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v.0); - JsonValue::String(format!( - "BLOB:{}:application/octet-stream:{}", - v.0.len(), - b64 - )) + JsonValue::String(crate::utils::blob::encode_blob(&v.0)) } } }; diff --git a/src/extract_tests.rs b/src/extract_tests.rs index 5e84147..c3a05bf 100644 --- a/src/extract_tests.rs +++ b/src/extract_tests.rs @@ -1283,6 +1283,39 @@ fn internal_statistics_blob_types_decode_and_accept_correctly() { )); } +#[test] +fn internal_statistics_blob_types_truncate_a_large_buffer_to_the_preview_cap() { + // #106: binary_blob_wrapper! had its own untruncated copy of the BLOB: + // encoding, independently drifted from the Type::BYTEA arm #87 fixed. + // A large value for any of these internal-stats types must also be + // capped at MAX_BLOB_PREVIEW_SIZE, with the header still reporting the + // true size -- same contract as an ordinary BYTEA column. + let large_buf = vec![0x37u8; 20 * 1024]; // 20 KB, over the 10 KB cap + + let v = PgNdistinct::from_sql(&Type::PG_NDISTINCT, &large_buf).unwrap(); + let serde_json::Value::String(wire) = serde_json::Value::from(v) else { + panic!("expected a string"); + }; + let header_prefix = format!("BLOB:{}:", large_buf.len()); + assert!( + wire.starts_with(&header_prefix), + "header must report the true size (20480), not the truncated preview size: {wire}" + ); + let mime_and_b64 = &wire[header_prefix.len()..]; + let (_, b64_payload) = mime_and_b64.split_once(':').unwrap(); + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_payload).unwrap(); + assert_eq!( + decoded.len(), + crate::utils::blob::MAX_BLOB_PREVIEW_SIZE, + "base64 payload must be truncated to the preview cap, not the full 20480 bytes" + ); + assert_eq!( + decoded, + large_buf[..crate::utils::blob::MAX_BLOB_PREVIEW_SIZE] + ); +} + #[test] fn tsvector_array_decodes_each_element() { let ty = array_type(Type::TS_VECTOR);