Skip to content

Commit c4fee4f

Browse files
committed
fix standalone footer validation
Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 5e48c79 commit c4fee4f

3 files changed

Lines changed: 109 additions & 39 deletions

File tree

vortex-file/src/footer/deserializer.rs

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ pub struct FooterDeserializer {
4040

4141
// Internal state that we accumulate
4242

43-
// The file size, possibly provided externally.
44-
file_size: Option<u64>,
43+
// The size of the byte stream containing the serialized footer.
44+
stream_size: Option<u64>,
4545
// The postscript, once we've parsed it.
4646
postscript: Option<Postscript>,
4747
}
@@ -52,7 +52,7 @@ impl FooterDeserializer {
5252
buffer: initial_read,
5353
session,
5454
dtype: None,
55-
file_size: None,
55+
stream_size: None,
5656
postscript: None,
5757
}
5858
}
@@ -71,15 +71,19 @@ impl FooterDeserializer {
7171
self
7272
}
7373

74-
/// Provide the total file size.
75-
pub fn with_size(mut self, file_size: u64) -> Self {
76-
self.file_size = Some(file_size);
74+
/// Provide the total size of the byte stream containing this serialized footer.
75+
///
76+
/// For a footer read from the end of a Vortex file, this is the file size. For a standalone
77+
/// blob created by [`crate::footer::FooterSerializer`] with its default offset, this is the
78+
/// size of that blob, not the size of the data file described by the footer.
79+
pub fn with_size(mut self, stream_size: u64) -> Self {
80+
self.stream_size = Some(stream_size);
7781
self
7882
}
7983

80-
/// Provide or clear the total file size.
81-
pub fn with_some_size(mut self, file_size: Option<u64>) -> Self {
82-
self.file_size = file_size;
84+
/// Provide or clear the total size of the byte stream containing this serialized footer.
85+
pub fn with_some_size(mut self, stream_size: Option<u64>) -> Self {
86+
self.stream_size = stream_size;
8387
self
8488
}
8589

@@ -120,11 +124,18 @@ impl FooterDeserializer {
120124
// The other postscript segments are required, so now we figure out our the offset that
121125
// contains all the required segments.
122126

123-
// The initial offset is the file size - the size of our initial read.
124-
let Some(file_size) = self.file_size else {
127+
// The initial offset is the byte stream size minus the size of our initial read.
128+
let Some(stream_size) = self.stream_size else {
125129
return Ok(DeserializeStep::NeedFileSize);
126130
};
127-
let initial_offset = file_size - (self.buffer.len() as u64);
131+
let initial_offset = stream_size
132+
.checked_sub(self.buffer.len() as u64)
133+
.ok_or_else(|| {
134+
vortex_err!(
135+
"Footer buffer length {} exceeds declared byte stream size {stream_size}",
136+
self.buffer.len()
137+
)
138+
})?;
128139

129140
let mut read_more_offset = initial_offset;
130141
if let Some(dtype_segment) = &dtype_segment {
@@ -269,20 +280,7 @@ impl FooterDeserializer {
269280
layout_segment,
270281
)?);
271282

272-
// The file size is always known by the time we parse the footer, since `deserialize`
273-
// returns `NeedFileSize` before reaching this point when it is missing.
274-
let file_size = self
275-
.file_size
276-
.vortex_expect("file size is required before parsing the footer");
277-
278-
Footer::from_flatbuffer(
279-
footer_bytes,
280-
layout_bytes,
281-
dtype,
282-
file_stats,
283-
file_size,
284-
&self.session,
285-
)
283+
Footer::from_flatbuffer(footer_bytes, layout_bytes, dtype, file_stats, &self.session)
286284
}
287285
}
288286

@@ -386,6 +384,25 @@ mod tests {
386384
assert!(err.to_string().contains("out of bounds"), "{err}");
387385
Ok(())
388386
}
387+
388+
#[test]
389+
fn deserialize_rejects_buffer_larger_than_declared_size() -> VortexResult<()> {
390+
let postscript = Postscript {
391+
dtype: None,
392+
layout: segment(0, 1),
393+
statistics: None,
394+
footer: segment(1, 1),
395+
};
396+
let buffer = eof_buffer(&postscript)?;
397+
let declared_size = buffer.len() as u64 - 1;
398+
399+
let mut deserializer = FooterDeserializer::new(buffer, array_session())
400+
.with_dtype(DType::Primitive(PType::I32, Nullability::NonNullable))
401+
.with_size(declared_size);
402+
let err = deserializer.deserialize().unwrap_err();
403+
assert!(err.to_string().contains("exceeds declared"), "{err}");
404+
Ok(())
405+
}
389406
}
390407

391408
#[derive(Debug)]
@@ -398,7 +415,7 @@ pub enum DeserializeStep {
398415
/// Number of bytes to read and prefix into the deserializer.
399416
len: usize,
400417
},
401-
/// The total file size is required before offsets can be resolved.
418+
/// The total size of the byte stream is required before offsets can be resolved.
402419
NeedFileSize,
403420
/// Footer deserialization is complete.
404421
Done(Footer),

vortex-file/src/footer/mod.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,11 @@ impl Footer {
7373
}
7474

7575
/// Read the [`Footer`] from a flatbuffer.
76-
///
77-
/// `file_size` is the total size of the file in bytes and is used to validate that every
78-
/// segment declared in the footer lies within the file.
7976
pub(crate) fn from_flatbuffer(
8077
footer_bytes: &[u8],
8178
layout_bytes: FlatBuffer,
8279
dtype: DType,
8380
statistics: Option<FileStatistics>,
84-
file_size: u64,
8581
session: &VortexSession,
8682
) -> VortexResult<Self> {
8783
let approx_byte_size = footer_bytes.len() + layout_bytes.len();
@@ -128,11 +124,6 @@ impl Footer {
128124
vortex_bail!("Segment offsets are not ordered");
129125
}
130126

131-
// A corrupt or malicious file can declare a segment whose offset or length extends past
132-
// the end of the file. Reject such files here so that later slicing of the backing buffer
133-
// returns a `VortexError` rather than panicking (see issue #8819).
134-
validate_segments_within_file(&segments, file_size)?;
135-
136127
Ok(Self {
137128
root_layout,
138129
segments,
@@ -181,6 +172,11 @@ impl Footer {
181172
self.root_layout.row_count()
182173
}
183174

175+
/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
176+
pub(crate) fn validate_file_size(&self, file_size: u64) -> VortexResult<()> {
177+
validate_segments_within_file(&self.segments, file_size)
178+
}
179+
184180
/// Returns a serializer for this footer.
185181
pub fn into_serializer(self) -> FooterSerializer {
186182
FooterSerializer::new(self)

vortex-file/src/open.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ impl VortexOpenOptions {
218218
Some(footer) => footer,
219219
None => block_on(opts.read_footer(&buffer))?,
220220
};
221+
footer.validate_file_size(buffer.len() as u64)?;
221222

222223
let segment_source = Arc::new(BufferSegmentSource::new(
223224
buffer,
@@ -246,6 +247,9 @@ impl VortexOpenOptions {
246247
.unwrap_or_else(|| Arc::new(DefaultMetricsRegistry::default()));
247248

248249
let footer = if let Some(footer) = self.footer {
250+
if let Some(file_size) = self.file_size {
251+
footer.validate_file_size(file_size)?;
252+
}
249253
footer
250254
} else {
251255
self.read_footer(&reader).await?
@@ -324,6 +328,11 @@ impl VortexOpenOptions {
324328
}
325329
}?;
326330

331+
// Segment specs describe the data file, not necessarily the byte stream used to
332+
// deserialize a standalone cached footer. Validate them here, where we know this is the
333+
// size of the actual data source (see issue #8819).
334+
footer.validate_file_size(file_size)?;
335+
327336
// If the initial read happened to cover any segments, then we can populate the
328337
// segment cache
329338
let initial_offset = file_size - (deserializer.buffer().len() as u64);
@@ -351,9 +360,9 @@ impl VortexOpenOptions {
351360
SegmentId::from(u32::try_from(idx).vortex_expect("Invalid segment ID"));
352361
let offset =
353362
usize::try_from(segment.offset - initial_offset).vortex_expect("Invalid offset");
354-
// The segment map is validated against the file size when the footer is parsed, but we
355-
// still bounds-check here so slicing never depends on that distant validation for
356-
// panic safety (see issue #8819).
363+
// The segment map is validated against the file size before this method is called, but
364+
// still bounds-check here so slicing never depends on that distant validation for panic
365+
// safety (see issue #8819).
357366
let end = offset
358367
.checked_add(segment.length as usize)
359368
.filter(|end| *end <= initial_read.len())
@@ -620,4 +629,52 @@ mod tests {
620629

621630
Ok(())
622631
}
632+
633+
#[tokio::test]
634+
async fn standalone_footer_round_trip() -> VortexResult<()> {
635+
let session = test_session();
636+
637+
let mut file_bytes = ByteBufferMut::empty();
638+
let values = (0i32..65_536)
639+
.map(|value| value.wrapping_mul(1_664_525).wrapping_add(1_013_904_223))
640+
.collect::<Vec<_>>();
641+
let array = Buffer::from(values).into_array();
642+
session
643+
.write_options()
644+
.write(&mut file_bytes, array.to_array_stream())
645+
.await?;
646+
let file_bytes = ByteBuffer::from(file_bytes);
647+
648+
let footer = session
649+
.open_options()
650+
.open_buffer(file_bytes.clone())?
651+
.footer()
652+
.clone();
653+
let last_segment_end = footer
654+
.segment_map()
655+
.iter()
656+
.map(|segment| segment.offset + u64::from(segment.length))
657+
.max()
658+
.unwrap_or_default();
659+
let serialized_footer = footer.into_serializer().serialize()?;
660+
let serialized_footer_size = serialized_footer.iter().map(ByteBuffer::len).sum();
661+
assert!(last_segment_end > serialized_footer_size as u64);
662+
let mut footer_bytes = ByteBufferMut::with_capacity(serialized_footer_size);
663+
for buffer in serialized_footer {
664+
footer_bytes.extend_from_slice(&buffer);
665+
}
666+
667+
let mut deserializer = Footer::deserializer(footer_bytes.freeze(), session.clone())
668+
.with_size(serialized_footer_size as u64);
669+
let DeserializeStep::Done(cached_footer) = deserializer.deserialize()? else {
670+
vortex_bail!("standalone footer bytes must be sufficient for deserialization");
671+
};
672+
673+
session
674+
.open_options()
675+
.with_footer(cached_footer)
676+
.open_buffer(file_bytes)?;
677+
678+
Ok(())
679+
}
623680
}

0 commit comments

Comments
 (0)