From 62fcbc8d64ecb2bfd10c785f88c7995a7b107438 Mon Sep 17 00:00:00 2001 From: Manuel Guilherme Date: Sat, 15 Aug 2026 06:16:23 -0300 Subject: [PATCH 1/2] fix: SkipWhitespace must not report EOF on an all-whitespace read SkipWhitespace::read returned Ok(0) whenever a single delegate read yielded a chunk that was entirely ASCII whitespace (n > 0 but 0 bytes written). Per the Read contract, Ok(0) on a non-empty buffer signals EOF, so the base64 DecoderReader (1024-byte internal buffer) treats a whitespace run of >= 1024 bytes as end-of-input and silently truncates the decoded XDR stream. Loop until at least one non-whitespace byte is produced, or the inner reader genuinely reaches EOF (returns 0). The impl lives in the generator template (header.rs) and is copied verbatim into generated.rs; both are updated identically. --- src/generated.rs | 27 +++++++++++++++++------- tests/tx_base64_skip_whitespace.rs | 29 ++++++++++++++++++++++++++ xdr-generator-rust/generator/header.rs | 27 +++++++++++++++++------- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/generated.rs b/src/generated.rs index 652f8a124..39bc1d64c 100644 --- a/src/generated.rs +++ b/src/generated.rs @@ -2419,17 +2419,28 @@ impl SkipWhitespace { #[cfg(feature = "std")] impl Read for SkipWhitespace { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let n = self.inner.read(buf)?; + loop { + let n = self.inner.read(buf)?; + if n == 0 { + return Ok(0); + } - let mut written = 0; - for read in 0..n { - if !buf[read].is_ascii_whitespace() { - buf[written] = buf[read]; - written += 1; + let mut written = 0; + for read in 0..n { + if !buf[read].is_ascii_whitespace() { + buf[written] = buf[read]; + written += 1; + } } - } - Ok(written) + // Only report EOF (Ok(0)) when the inner reader is genuinely at end + // (n == 0 above). If an entire read was whitespace, keep reading + // instead of returning Ok(0), which the caller treats as EOF and + // would use to silently truncate the decoded stream. + if written > 0 { + return Ok(written); + } + } } } diff --git a/tests/tx_base64_skip_whitespace.rs b/tests/tx_base64_skip_whitespace.rs index 366dc8580..52647b28c 100644 --- a/tests/tx_base64_skip_whitespace.rs +++ b/tests/tx_base64_skip_whitespace.rs @@ -6,6 +6,35 @@ use std::io::Cursor; use stellar_xdr::Error; use stellar_xdr::{Limited, Limits, ReadXdr, WriteXdr}; +#[test] +fn test_skip_whitespace_long_run() -> Result<(), Error> { + // A whitespace run at least as long as the base64 decoder's internal read + // buffer (1024 bytes) must not be treated as end-of-input. Before the fix, + // SkipWhitespace::read returned Ok(0) when a whole delegate read was + // whitespace, which the decoder interprets as EOF -> silent truncation. + let v_bytes = [1u32.to_xdr(Limits::none())?, 2u32.to_xdr(Limits::none())?].concat(); + let core = base64::engine::general_purpose::STANDARD.encode(&v_bytes); + assert_eq!(core, "AAAAAQAAAAI="); + let ws = " ".repeat(2048); + + // Leading long whitespace run. + let leading = format!("{ws}{core}"); + assert_eq!( + u64::from_xdr_base64(&leading, Limits::none()), + Ok((1u64 << 32) | 2u64) + ); + + // Interior long whitespace run (split the base64 mid-string). + let (a, b) = core.split_at(4); + let interior = format!("{a}{ws}{b}"); + assert_eq!( + u64::from_xdr_base64(&interior, Limits::none()), + Ok((1u64 << 32) | 2u64) + ); + + Ok(()) +} + #[test] fn test_skip_whitespace() -> Result<(), Error> { let v_bytes = [1u32.to_xdr(Limits::none())?, 2u32.to_xdr(Limits::none())?].concat(); diff --git a/xdr-generator-rust/generator/header.rs b/xdr-generator-rust/generator/header.rs index 7418b9f6a..bf9f3683c 100644 --- a/xdr-generator-rust/generator/header.rs +++ b/xdr-generator-rust/generator/header.rs @@ -2342,17 +2342,28 @@ impl SkipWhitespace { #[cfg(feature = "std")] impl Read for SkipWhitespace { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let n = self.inner.read(buf)?; + loop { + let n = self.inner.read(buf)?; + if n == 0 { + return Ok(0); + } - let mut written = 0; - for read in 0..n { - if !buf[read].is_ascii_whitespace() { - buf[written] = buf[read]; - written += 1; + let mut written = 0; + for read in 0..n { + if !buf[read].is_ascii_whitespace() { + buf[written] = buf[read]; + written += 1; + } } - } - Ok(written) + // Only report EOF (Ok(0)) when the inner reader is genuinely at end + // (n == 0 above). If an entire read was whitespace, keep reading + // instead of returning Ok(0), which the caller treats as EOF and + // would use to silently truncate the decoded stream. + if written > 0 { + return Ok(written); + } + } } } From 492d03faebf9f858354cee9e40de989bfdbaacf2 Mon Sep 17 00:00:00 2001 From: Manuel Guilherme Date: Sat, 15 Aug 2026 09:45:58 -0300 Subject: [PATCH 2/2] test: don't hard-code the base64 crate's internal buffer size in the comment Copilot review: 1024 is an implementation detail of the base64 crate. Reword to 'a long contiguous whitespace run (2048 bytes here, large enough to fill at least one of the decoder's internal read buffers)' so the test comment doesn't assert a specific private constant. --- tests/tx_base64_skip_whitespace.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/tx_base64_skip_whitespace.rs b/tests/tx_base64_skip_whitespace.rs index 52647b28c..ec31193c2 100644 --- a/tests/tx_base64_skip_whitespace.rs +++ b/tests/tx_base64_skip_whitespace.rs @@ -8,10 +8,11 @@ use stellar_xdr::{Limited, Limits, ReadXdr, WriteXdr}; #[test] fn test_skip_whitespace_long_run() -> Result<(), Error> { - // A whitespace run at least as long as the base64 decoder's internal read - // buffer (1024 bytes) must not be treated as end-of-input. Before the fix, - // SkipWhitespace::read returned Ok(0) when a whole delegate read was - // whitespace, which the decoder interprets as EOF -> silent truncation. + // A long contiguous whitespace run (2048 bytes here, large enough to fill + // at least one of the base64 decoder's internal read buffers) must not be + // treated as end-of-input. Before the fix, SkipWhitespace::read returned + // Ok(0) when a whole delegate read was whitespace, which the decoder + // interprets as EOF -> silent truncation. let v_bytes = [1u32.to_xdr(Limits::none())?, 2u32.to_xdr(Limits::none())?].concat(); let core = base64::engine::general_purpose::STANDARD.encode(&v_bytes); assert_eq!(core, "AAAAAQAAAAI=");