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);