This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new cc49a83f44 fix(arrow-avro): fix split sync marker assembly and
validate per-block sync markers (#10497)
cc49a83f44 is described below
commit cc49a83f4422f8bcbcb06f908505112cf96bce3d
Author: ranflarion <[email protected]>
AuthorDate: Mon Aug 3 19:38:27 2026 -0400
fix(arrow-avro): fix split sync marker assembly and validate per-block sync
markers (#10497)
# Which issue does this PR close?
- Closes #10494.
- Closes #10495.
# Rationale for this change
Both `BlockDecoder` and `HeaderDecoder` assemble the 16-byte sync marker
with an offset derived from the size of the incoming fragment (`sync[16
- to_decode..]`), which only works when all 16 bytes arrive in one
`decode()` call; a marker straddling a chunk boundary gets scrambled
(#10494). For `HeaderDecoder` this is live today: a header larger than
one fetch whose trailing marker straddles the boundary yields a garbled
`Header::sync()`, the async reader's sync marker scan never matches, and
a valid file silently reads as an empty stream.
With assembly fixed, `Block.sync` becomes trustworthy, enabling the
validation the Avro spec intends the marker for: both readers now
compare each block's trailing marker against the file header's and
reject a mismatch, matching the Java reference reader (`DataFileStream`
throws `Invalid sync!`) (#10495). This continues the recent hardening of
the container decode path (#10237, #10407). Without it, a desynced or
corrupted stream can decode silently into wrong values; avro-deflate
block data is raw DEFLATE with no checksum, so this is a real
silent-corruption window with transiently faulty object stores.
# What changes are included in this PR?
- `BlockDecoder` and `HeaderDecoder` fill the sync marker from the front
using the consumed count (`offset = 16 - bytes_remaining`).
- The sync `Reader` and the async reader error with `ParseError("Avro
block sync marker does not match file header")` when a flushed block's
marker differs from the header's.
# Are these changes tested?
- A `BlockDecoder` unit test feeds a block in every chunk size from 1
byte up and asserts the assembled marker; it fails on main.
- Both readers get a test that flips the final byte of
`alltypes_plain.avro` (the last block's sync marker) and asserts the
read fails; on main the flip is accepted silently.
- Existing suites pass unchanged, including the roundtrip and range-read
tests.
# Are there any user-facing changes?
Corrupt files that previously decoded silently (or, for the header case,
silently produced an empty stream) now error, matching the Java reader.
Appended files reuse the file's marker, so any file the reference reader
accepts still reads fine.
Co-authored-by: Jeffrey Vo <[email protected]>
---
arrow-avro/src/reader/async_reader/mod.rs | 23 +++++++++++++++++++++++
arrow-avro/src/reader/block.rs | 25 +++++++++++++++++++++++--
arrow-avro/src/reader/header.rs | 5 +++--
arrow-avro/src/reader/mod.rs | 22 ++++++++++++++++++++++
4 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/arrow-avro/src/reader/async_reader/mod.rs
b/arrow-avro/src/reader/async_reader/mod.rs
index 84ba1c873b..7ab0ee3efb 100644
--- a/arrow-avro/src/reader/async_reader/mod.rs
+++ b/arrow-avro/src/reader/async_reader/mod.rs
@@ -389,6 +389,11 @@ impl<R: AsyncFileReader + Unpin + 'static>
AsyncAvroFileReader<R> {
// If we reached the end of the block, flush it, and move
to read batches.
if let Some(block) = self.block_decoder.flush() {
// Successfully decoded a block.
+ if block.sync != self.sync_marker {
+ return
self.finish_with_error(AvroError::ParseError(
+ "Avro block sync marker does not match file
header".to_string(),
+ ));
+ }
let block_count = block.count;
let block_data = Bytes::from_owner(if let Some(ref
codec) = self.codec {
match codec.decompress(&block.data) {
@@ -1125,6 +1130,24 @@ mod tests {
assert!(batch.num_rows() > 0);
}
+ #[tokio::test]
+ async fn test_block_sync_marker_mismatch_errors() {
+ use tempfile::tempdir;
+ let file = arrow_test_data("avro/alltypes_plain.avro");
+ let mut bytes = std::fs::read(&file).unwrap();
+ // The file ends with the final block's 16-byte sync marker.
+ let last = bytes.len() - 1;
+ bytes[last] ^= 0xFF;
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("corrupt_sync.avro");
+ std::fs::write(&path, bytes).unwrap();
+ let schema = get_alltypes_schema();
+ let err = read_async_file(path.to_str().unwrap(), 1024, None,
Some(schema), None)
+ .await
+ .expect_err("corrupted block sync marker should fail the read");
+ assert!(err.to_string().contains("sync marker"), "{err}");
+ }
+
#[tokio::test]
async fn test_range_no_sync_marker() {
// Small range unlikely to contain sync marker
diff --git a/arrow-avro/src/reader/block.rs b/arrow-avro/src/reader/block.rs
index 3ec6e507ea..44161e2646 100644
--- a/arrow-avro/src/reader/block.rs
+++ b/arrow-avro/src/reader/block.rs
@@ -118,8 +118,10 @@ impl BlockDecoder {
}
BlockDecoderState::Sync => {
let to_decode = buf.len().min(self.bytes_remaining);
- let write = &mut self.in_progress.sync[16 - to_decode..];
- write[..to_decode].copy_from_slice(&buf[..to_decode]);
+ // Fill from the front: the marker may arrive split across
decode() calls.
+ let offset = 16 - self.bytes_remaining;
+ self.in_progress.sync[offset..offset + to_decode]
+ .copy_from_slice(&buf[..to_decode]);
self.bytes_remaining -= to_decode;
buf = &buf[to_decode..];
if self.bytes_remaining == 0 {
@@ -222,4 +224,23 @@ mod tests {
assert_eq!(block.data, payload);
assert_eq!(block.sync, sync);
}
+
+ #[test]
+ fn test_sync_marker_split_across_decode_calls() {
+ // count=1 (zig-zag 0x02), size=1 (0x02), one data byte, 16-byte sync
marker
+ let sync: [u8; 16] = core::array::from_fn(|i| i as u8);
+ let mut block_bytes = vec![0x02, 0x02, 0xAA];
+ block_bytes.extend_from_slice(&sync);
+
+ for chunk_size in 1..block_bytes.len() {
+ let mut decoder = BlockDecoder::default();
+ for chunk in block_bytes.chunks(chunk_size) {
+ decoder.decode(chunk).unwrap();
+ }
+ let block = decoder.flush().expect("complete block");
+ assert_eq!(block.count, 1);
+ assert_eq!(block.data, vec![0xAA]);
+ assert_eq!(block.sync, sync, "chunk_size {chunk_size}");
+ }
+ }
}
diff --git a/arrow-avro/src/reader/header.rs b/arrow-avro/src/reader/header.rs
index 235166bb76..9c28ef64fe 100644
--- a/arrow-avro/src/reader/header.rs
+++ b/arrow-avro/src/reader/header.rs
@@ -314,8 +314,9 @@ impl HeaderDecoder {
}
HeaderDecoderState::Sync => {
let to_decode = buf.len().min(self.bytes_remaining);
- let write = &mut self.sync_marker[16 - to_decode..];
- write[..to_decode].copy_from_slice(&buf[..to_decode]);
+ // Fill from the front: the marker may arrive split across
decode() calls.
+ let offset = 16 - self.bytes_remaining;
+ self.sync_marker[offset..offset +
to_decode].copy_from_slice(&buf[..to_decode]);
self.bytes_remaining -= to_decode;
buf = &buf[to_decode..];
if self.bytes_remaining == 0 {
diff --git a/arrow-avro/src/reader/mod.rs b/arrow-avro/src/reader/mod.rs
index 1b966060e4..a9ec035a70 100644
--- a/arrow-avro/src/reader/mod.rs
+++ b/arrow-avro/src/reader/mod.rs
@@ -1371,6 +1371,11 @@ impl<R: BufRead> Reader<R> {
self.reader.consume(consumed);
if let Some(block) = self.block_decoder.flush() {
// Successfully decoded a block.
+ if block.sync != self.header.sync() {
+ return Err(AvroError::ParseError(
+ "Avro block sync marker does not match file
header".to_string(),
+ ));
+ }
self.block_data = if let Some(ref codec) =
self.header.compression()? {
let decompressed: Vec<u8> =
codec.decompress(&block.data)?;
decompressed
@@ -1492,6 +1497,23 @@ mod test {
arrow::compute::concat_batches(&schema, &batches).unwrap()
}
+ #[test]
+ fn test_block_sync_marker_mismatch_errors() {
+ let path = arrow_test_data("avro/alltypes_plain.avro");
+ let mut bytes = std::fs::read(&path).unwrap();
+ // The file ends with the final block's 16-byte sync marker.
+ let last = bytes.len() - 1;
+ bytes[last] ^= 0xFF;
+ let reader = ReaderBuilder::new()
+ .with_batch_size(1024)
+ .build(std::io::Cursor::new(bytes))
+ .unwrap();
+ let err = reader
+ .collect::<Result<Vec<_>, _>>()
+ .expect_err("corrupted block sync marker should fail the read");
+ assert!(err.to_string().contains("sync marker"), "{err}");
+ }
+
fn read_file_strict(
path: &str,
batch_size: usize,