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
8 changes: 2 additions & 6 deletions src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ fn extract_simple_kind(col_type: &Type, row: &Row, index: usize) -> JsonValue {
try_extract::<serde_json::Value>(row, index, |v| v)
}
ref t if *t == Type::BYTEA => try_extract::<Vec<u8>>(row, index, |v| {
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v);
JsonValue::String(format!("BLOB:{}:application/octet-stream:{}", v.len(), b64))
JsonValue::String(crate::utils::blob::encode_blob(&v))
}),
ref t if *t == Type::INET || *t == Type::CIDR => {
try_extract::<CidrOrInet>(row, index, JsonValue::from)
Expand Down Expand Up @@ -878,10 +877,7 @@ fn extract_simple_kind_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue {
serde_json::Value::from_sql(ty, buf).unwrap_or(JsonValue::Null)
}
_ if *ty == Type::BYTEA => Vec::<u8>::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))
})
.map(|v| JsonValue::String(crate::utils::blob::encode_blob(&v)))
.unwrap_or(JsonValue::Null),
_ if *ty == Type::INET || *ty == Type::CIDR => CidrOrInet::from_sql(ty, buf)
.map(JsonValue::from)
Expand Down
42 changes: 42 additions & 0 deletions src/extract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,48 @@ fn hstore_array_decodes_each_element_as_a_json_object() {
assert_eq!(array.0, serde_json::json!([{"a": "1"}, {"b": "2"}]));
}

#[test]
fn bytea_array_element_uses_the_same_truncated_preview_encoding_as_a_scalar_bytea_column() {
// #87: a bytea column reached through the array-element decode path
// (extract_kind_from_bytes -> extract_simple_kind_from_bytes) previously
// had its OWN untruncated copy of the BLOB: encoding, independently
// drifted from the scalar Type::BYTEA arm this issue fixed. Prove a
// large element in a bytea[] column is also truncated to
// MAX_BLOB_PREVIEW_SIZE, with the header still reporting the true size.
let ty = array_type(Type::BYTEA);
let large_elem = vec![0x41u8; 20 * 1024]; // 20 KB, over the 10 KB cap
let small_elem = vec![0xCAu8, 0xFE, 0xBA, 0xBE];
let bytes = array_wire_bytes(Type::BYTEA.oid(), &[Some(&large_elem), Some(&small_elem)]);
let array = ArrayValue::from_sql(&ty, &bytes).unwrap();
let serde_json::Value::Array(elems) = array.0 else {
panic!("expected a JSON array");
};

let serde_json::Value::String(large_wire) = &elems[0] else {
panic!("expected a string");
};
let header_prefix = format!("BLOB:{}:", large_elem.len());
assert!(
large_wire.starts_with(&header_prefix),
"the header must report the TRUE size (20480), not the truncated preview size: \
{large_wire}"
);
let mime_and_b64 = &large_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,
"the base64 payload for a bytea[] element must be truncated to the preview cap too"
);

assert_eq!(
elems[1],
serde_json::json!("BLOB:4:application/octet-stream:yv66vg==")
);
}

// Coverage for a gap found during a thoroughness pass on #82: the array
// decoder (`extract_element_from_bytes`) is a *separate* per-element
// dispatch table from the scalar dispatch (`extract_simple_kind`) — adding
Expand Down
18 changes: 4 additions & 14 deletions src/handlers/blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value {
.unwrap_or_default();

match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await {
Ok(bytes) => ok_response(id, Value::from(encode_blob_full(&bytes))),
Ok(bytes) => ok_response(
id,
Value::from(crate::utils::blob::encode_blob_full(&bytes)),
),
Err(e) => error_response(id, -32603, &e),
}
}
Expand Down Expand Up @@ -126,19 +129,6 @@ fn validate_writable_file_path(file_path: &str) -> Result<(), String> {
}
}

/// Encode raw bytes into the canonical BLOB wire format:
/// `"BLOB:<size>:<mime_type>:<base64_data>"`. MIME type is sniffed from the
/// content's magic bytes; unrecognized content falls back to
/// `application/octet-stream`. Matches `encode_blob_full` in
/// `src-tauri/src/drivers/common/blob.rs`.
fn encode_blob_full(data: &[u8]) -> String {
let mime_type = infer::get(data)
.map(|k| k.mime_type())
.unwrap_or("application/octet-stream");
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data);
format!("BLOB:{}:{}:{}", data.len(), mime_type, b64)
}

#[cfg(test)]
#[path = "blob_tests.rs"]
mod blob_tests;
35 changes: 6 additions & 29 deletions src/handlers/blob_tests.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
//! Unit tests for `blob.rs`'s pure encoding helper. Sibling test file per
//! repo convention (`.rules/rust.md` #4/#5) — loaded via
//! `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`.

use super::{encode_blob_full, validate_writable_file_path};

#[test]
fn encodes_size_mime_and_base64() {
// 4 bytes (0xCA 0xFE 0xBA 0xBE) — not a recognized magic-byte format, so
// infer falls back to application/octet-stream.
let bytes = [0xCA, 0xFE, 0xBA, 0xBE];
let wire = encode_blob_full(&bytes);
assert_eq!(wire, "BLOB:4:application/octet-stream:yv66vg==");
}

#[test]
fn empty_input_encodes_zero_size() {
let wire = encode_blob_full(&[]);
assert_eq!(wire, "BLOB:0:application/octet-stream:");
}

#[test]
fn sniffs_recognized_magic_bytes() {
// PNG signature.
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let wire = encode_blob_full(&bytes);
assert!(wire.starts_with("BLOB:8:image/png:"));
}
//! Unit tests for `blob.rs`'s pure helper (`validate_writable_file_path`).
//! `encode_blob`/`encode_blob_full` moved to `crate::utils::blob` (#87) —
//! their tests live in `utils/blob.rs`'s own sibling test module now.
//! Sibling test file per repo convention (`.rules/rust.md` #4/#5) — loaded
//! via `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`.

mod validate_writable_file_path_tests {
use super::validate_writable_file_path;
use super::super::validate_writable_file_path;

#[test]
fn rejects_empty_path() {
Expand Down
48 changes: 48 additions & 0 deletions src/utils/blob.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! Shared BLOB (bytea) wire-format encoding, used by both the read path
//! (`extract.rs`'s `Type::BYTEA` arm) and the file-export path
//! (`handlers/blob.rs::fetch_blob_as_data_url`).
//!
//! Matches `src-tauri/src/drivers/common/blob.rs` exactly.

/// Maximum number of bytes base64-encoded into a read/preview response.
/// Larger values are truncated to this many bytes before encoding — the
/// `BLOB:` header still reports the true, untruncated size, so the UI knows
/// the real length without paying to transfer it.
pub const MAX_BLOB_PREVIEW_SIZE: usize = 10_240;

/// Encode raw bytes into the canonical BLOB wire format for the read/preview
/// path: `"BLOB:<total_size>:<mime_type>:<base64_data>"`. Truncates the
/// encoded payload to [`MAX_BLOB_PREVIEW_SIZE`] bytes (MIME is sniffed from
/// that same truncated preview, matching the builtin), while `total_size`
/// always reports the untruncated length.
pub fn encode_blob(data: &[u8]) -> String {
let total_size = data.len();
let preview = if total_size > MAX_BLOB_PREVIEW_SIZE {
&data[..MAX_BLOB_PREVIEW_SIZE]
} else {
data
};

let mime_type = infer::get(preview)
.map(|k| k.mime_type())
.unwrap_or("application/octet-stream");
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, preview);

format!("BLOB:{}:{}:{}", total_size, mime_type, b64)
}

/// Encode raw bytes into the canonical BLOB wire format, preserving the
/// complete data with no truncation — used by upload/write/export paths so
/// files aren't silently truncated.
pub fn encode_blob_full(data: &[u8]) -> String {
let mime_type = infer::get(data)
.map(|k| k.mime_type())
.unwrap_or("application/octet-stream");
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data);

format!("BLOB:{}:{}:{}", data.len(), mime_type, b64)
}

#[cfg(test)]
#[path = "blob_tests.rs"]
mod blob_tests;
136 changes: 136 additions & 0 deletions src/utils/blob_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! Unit tests for `blob.rs`'s BLOB wire-format encoders. Sibling test file
//! per repo convention (`.rules/rust.md` #4/#5) — loaded via
//! `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`.

use super::{encode_blob, encode_blob_full, MAX_BLOB_PREVIEW_SIZE};

mod encode_blob_tests {
use super::*;

#[test]
fn encodes_size_mime_and_base64() {
// 4 bytes (0xCA 0xFE 0xBA 0xBE) — not a recognized magic-byte format,
// so infer falls back to application/octet-stream.
let bytes = [0xCA, 0xFE, 0xBA, 0xBE];
let wire = encode_blob(&bytes);
assert_eq!(wire, "BLOB:4:application/octet-stream:yv66vg==");
}

#[test]
fn empty_input_encodes_zero_size() {
let wire = encode_blob(&[]);
assert_eq!(wire, "BLOB:0:application/octet-stream:");
}

#[test]
fn sniffs_recognized_magic_bytes() {
// PNG signature.
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let wire = encode_blob(&bytes);
assert!(wire.starts_with("BLOB:8:image/png:"));
}

#[test]
fn small_blob_under_preview_cap_encodes_in_full() {
let bytes = vec![0x42u8; 100];
let wire = encode_blob(&bytes);
let header = format!("BLOB:{}:application/octet-stream:", bytes.len());
assert!(wire.starts_with(&header));
let b64_payload = &wire[header.len()..];
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_payload)
.unwrap();
assert_eq!(
decoded, bytes,
"under the cap, the full payload must round-trip"
);
}

#[test]
fn large_blob_reports_true_size_but_truncates_payload_to_preview_cap() {
// 20 KB input -- outlasts MAX_BLOB_PREVIEW_SIZE (10 KB), per #87.
let total_size = 20 * 1024;
let bytes = vec![0x41u8; total_size];
let wire = encode_blob(&bytes);

let header_prefix = format!("BLOB:{}:", total_size);
assert!(
wire.starts_with(&header_prefix),
"the BLOB: header must report the TRUE total size (20480), not the truncated \
preview size, so the UI knows the real length: {wire}"
);

let mime_and_b64 = &wire[header_prefix.len()..];
let (_, b64_payload) = mime_and_b64.split_once(':').expect("mime:base64 shape");
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_payload)
.unwrap();
assert_eq!(
decoded.len(),
MAX_BLOB_PREVIEW_SIZE,
"the base64 payload must only cover the first {MAX_BLOB_PREVIEW_SIZE} bytes, \
not the full 20480-byte input"
);
assert_eq!(decoded, bytes[..MAX_BLOB_PREVIEW_SIZE]);
}

#[test]
fn blob_exactly_at_preview_cap_is_not_truncated() {
let bytes = vec![0x99u8; MAX_BLOB_PREVIEW_SIZE];
let wire = encode_blob(&bytes);
let header_prefix = format!("BLOB:{}:", MAX_BLOB_PREVIEW_SIZE);
assert!(wire.starts_with(&header_prefix));
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(),
MAX_BLOB_PREVIEW_SIZE,
"the boundary itself must not truncate"
);
}
}

mod encode_blob_full_tests {
use super::*;

#[test]
fn encodes_size_mime_and_base64() {
let bytes = [0xCA, 0xFE, 0xBA, 0xBE];
let wire = encode_blob_full(&bytes);
assert_eq!(wire, "BLOB:4:application/octet-stream:yv66vg==");
}

#[test]
fn empty_input_encodes_zero_size() {
let wire = encode_blob_full(&[]);
assert_eq!(wire, "BLOB:0:application/octet-stream:");
}

#[test]
fn sniffs_recognized_magic_bytes() {
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let wire = encode_blob_full(&bytes);
assert!(wire.starts_with("BLOB:8:image/png:"));
}

#[test]
fn large_blob_is_never_truncated() {
// Unlike encode_blob, the full-fidelity encoder must preserve every
// byte regardless of size -- this is what the file-export path
// (fetch_blob_as_data_url) relies on to not silently corrupt files.
let total_size = 20 * 1024;
let bytes = vec![0x41u8; total_size];
let wire = encode_blob_full(&bytes);

let header_prefix = format!("BLOB:{}:", total_size);
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, bytes, "encode_blob_full must never truncate");
}
}
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Utility modules.

pub mod blob;
pub mod identifiers;
pub mod pagination;
#[cfg(test)]
Expand Down
Loading