Skip to main content

vortex_file/footer/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Vortex file footer metadata.
5//!
6//! A footer contains the root layout, file-level statistics, the segment map, and the read contexts
7//! needed to resolve array/layout encoding ids during deserialization.
8//!
9//! The byte-level footer and postscript layout is part of the file-format spec; this module exposes
10//! the structured Rust representation and serializer/deserializer state machine.
11mod field_sizes;
12mod file_layout;
13mod file_statistics;
14mod postscript;
15mod segment;
16
17use std::sync::Arc;
18
19mod serializer;
20pub use serializer::*;
21mod deserializer;
22pub use deserializer::*;
23pub use field_sizes::CompressedFieldSizes;
24pub use file_statistics::FileStatistics;
25use flatbuffers::root;
26use itertools::Itertools;
27pub use segment::*;
28use vortex_array::ArrayId;
29use vortex_array::dtype::DType;
30use vortex_array::flatbuffers::FlatBuffer;
31use vortex_buffer::ByteBuffer;
32use vortex_error::VortexResult;
33use vortex_error::vortex_bail;
34use vortex_error::vortex_err;
35use vortex_layout::LayoutEncodingId;
36use vortex_layout::LayoutRef;
37use vortex_layout::layout_from_flatbuffer_with_options;
38use vortex_session::VortexSession;
39use vortex_session::registry::ReadContext;
40
41use crate::flatbuffers::footer as fb;
42
43/// Maximum number of user-defined metadata segments. Keeps postscript bookkeeping small so the
44/// footer and required segments still fit the initial tail read.
45pub(crate) const MAX_METADATA_SEGMENTS: usize = 16;
46
47/// Maximum length, in UTF-8 bytes, of a user-defined metadata key (keys live in the postscript).
48///
49/// 64 bytes covers reverse-DNS query-engine keys, not just short Iceberg-style keys: e.g.
50/// `org.apache.spark.sql.parquet.row.metadata` (41 bytes), which Spark writes into every Parquet
51/// file. With [`MAX_METADATA_SEGMENTS`] keys this bounds the postscript key budget at 1 KiB.
52pub(crate) const MAX_METADATA_KEY_BYTES: usize = 64;
53
54/// User-defined metadata segment locators stored as `(key, locator)` pairs.
55pub(crate) type MetadataSegments = Arc<[(String, SegmentSpec)]>;
56
57/// Captures the layout information of a Vortex file.
58#[derive(Debug, Clone)]
59pub struct Footer {
60    root_layout: LayoutRef,
61    segments: Arc<[SegmentSpec]>,
62    statistics: Option<FileStatistics>,
63    metadata: Arc<[(String, SegmentSpec)]>,
64    // `segments` followed by the `metadata` segments, which is the segment table a file is opened
65    // with. Precomputed so opening a file with a cached footer does not copy every segment spec.
66    segments_with_metadata: Arc<[SegmentSpec]>,
67    // The largest end offset of any segment in `segments`, or `None` if one overflows `u64`.
68    // Precomputed so validating a cached footer against a file size does not walk every segment.
69    // Writers never overlap segments, so in a valid file this is the last segment's end, but only
70    // their order is checked when reading, and this guards against malformed footers.
71    segments_end: Option<u64>,
72    // The specific arrays used within the file, in the order they were registered.
73    array_read_ctx: ReadContext,
74    // The approximate size of the footer in bytes, used for caching and memory management.
75    approx_byte_size: Option<usize>,
76}
77
78impl Footer {
79    pub fn new(
80        root_layout: LayoutRef,
81        segments: Arc<[SegmentSpec]>,
82        statistics: Option<FileStatistics>,
83        array_read_ctx: ReadContext,
84    ) -> Self {
85        Self {
86            root_layout,
87            segments_with_metadata: Arc::clone(&segments),
88            segments_end: segments_end(&segments),
89            segments,
90            statistics,
91            metadata: Arc::from([]),
92            array_read_ctx,
93            approx_byte_size: None,
94        }
95    }
96
97    pub(crate) fn with_approx_byte_size(mut self, approx_byte_size: usize) -> Self {
98        self.approx_byte_size = Some(approx_byte_size);
99        self
100    }
101
102    /// Read the [`Footer`] from a flatbuffer.
103    pub(crate) fn from_flatbuffer(
104        footer_bytes: &[u8],
105        layout_bytes: FlatBuffer,
106        dtype: DType,
107        statistics: Option<FileStatistics>,
108        metadata: Arc<[(String, SegmentSpec)]>,
109        session: &VortexSession,
110    ) -> VortexResult<Self> {
111        let metadata_bytes: usize = metadata
112            .iter()
113            .map(|(key, _segment)| key.len() + size_of::<SegmentSpec>())
114            .sum();
115        let approx_byte_size = footer_bytes.len() + layout_bytes.len() + metadata_bytes;
116        let fb_footer = root::<fb::Footer>(footer_bytes)?;
117
118        // Create a LayoutContext from the registry.
119        let layout_specs = fb_footer.layout_specs();
120        #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
121        let layout_ids: Arc<[_]> = layout_specs
122            .iter()
123            .flat_map(|e| e.iter())
124            .map(|encoding| LayoutEncodingId::new(encoding.id()))
125            .collect();
126        let layout_read_ctx = ReadContext::new(layout_ids);
127
128        // Create an ArrayContext from the registry.
129        let array_specs = fb_footer.array_specs();
130        #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
131        let array_ids: Arc<[_]> = array_specs
132            .iter()
133            .flat_map(|e| e.iter())
134            .map(|encoding| ArrayId::new(encoding.id()))
135            .collect();
136        let array_read_ctx = ReadContext::new(array_ids);
137
138        let root_layout = layout_from_flatbuffer_with_options(
139            layout_bytes,
140            &dtype,
141            &layout_read_ctx,
142            &array_read_ctx,
143            session,
144            session.allows_unknown(),
145        )?;
146
147        let segments: Arc<[SegmentSpec]> = fb_footer
148            .segment_specs()
149            .ok_or_else(|| vortex_err!("FileLayout missing segment specs"))?
150            .iter()
151            .map(SegmentSpec::try_from)
152            .try_collect()?;
153
154        // Note this assertion is `<=` since we allow zero-length segments
155        if !segments.is_sorted_by_key(|segment| segment.offset) {
156            vortex_bail!("Segment offsets are not ordered");
157        }
158
159        Ok(Self {
160            root_layout,
161            segments_with_metadata: segments_with_metadata(&segments, &metadata),
162            segments_end: segments_end(&segments),
163            segments,
164            statistics,
165            metadata,
166            array_read_ctx,
167            approx_byte_size: Some(approx_byte_size),
168        })
169    }
170
171    /// Returns the root [`LayoutRef`] of the file.
172    pub fn layout(&self) -> &LayoutRef {
173        &self.root_layout
174    }
175
176    /// Returns the segment map of the file.
177    pub fn segment_map(&self) -> &Arc<[SegmentSpec]> {
178        &self.segments
179    }
180
181    /// Returns the statistics of the file.
182    pub fn statistics(&self) -> Option<&FileStatistics> {
183        self.statistics.as_ref()
184    }
185
186    /// Returns the user-defined metadata segment locators stored in the postscript.
187    pub fn metadata_segments(&self) -> impl Iterator<Item = (&str, &SegmentSpec)> {
188        self.metadata
189            .iter()
190            .map(|(key, segment)| (key.as_str(), segment))
191    }
192
193    /// Returns the user-defined metadata segment locator for the given key.
194    pub fn metadata_segment(&self, key: &str) -> Option<&SegmentSpec> {
195        self.metadata
196            .iter()
197            .find_map(|(candidate, segment)| (candidate == key).then_some(segment))
198    }
199
200    pub(crate) fn with_metadata_segments(mut self, metadata: Arc<[(String, SegmentSpec)]>) -> Self {
201        self.segments_with_metadata = segments_with_metadata(&self.segments, &metadata);
202        self.metadata = metadata;
203        self
204    }
205
206    pub(crate) fn segment_specs_with_metadata(&self) -> Arc<[SegmentSpec]> {
207        Arc::clone(&self.segments_with_metadata)
208    }
209
210    /// Computes the compressed size in bytes of every field in the file, keyed by field path.
211    ///
212    /// Sizes are derived by attributing each segment in the [segment map][Self::segment_map] to a
213    /// field in the [layout tree][Self::layout]; see [`CompressedFieldSizes`] for the exact
214    /// attribution semantics. No IO is performed.
215    pub fn compressed_field_sizes(&self) -> VortexResult<CompressedFieldSizes> {
216        CompressedFieldSizes::try_new(&self.root_layout, &self.segments)
217    }
218
219    /// Returns the [`DType`] of the file.
220    pub fn dtype(&self) -> &DType {
221        self.root_layout.dtype()
222    }
223
224    /// Returns the approximate size of the footer in bytes, used for caching and memory management.
225    pub fn approx_byte_size(&self) -> Option<usize> {
226        self.approx_byte_size
227    }
228
229    /// Returns the number of rows in the file.
230    pub fn row_count(&self) -> u64 {
231        self.root_layout.row_count()
232    }
233
234    /// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
235    pub(crate) fn validate_file_size(&self, file_size: u64) -> VortexResult<()> {
236        if self.segments_end.is_some_and(|end| end <= file_size) {
237            return Ok(());
238        }
239        // Walk the segments only to name the offending one in the error.
240        validate_segments_within_file(&self.segments, file_size)
241    }
242
243    /// Returns a serializer for this footer.
244    pub fn into_serializer(self) -> FooterSerializer {
245        FooterSerializer::new(self)
246    }
247
248    /// Create a deserializer for a Vortex file footer.
249    pub fn deserializer(eof_buffer: ByteBuffer, session: VortexSession) -> FooterDeserializer {
250        FooterDeserializer::new(eof_buffer, session)
251    }
252}
253
254fn segments_with_metadata(
255    segments: &Arc<[SegmentSpec]>,
256    metadata: &[(String, SegmentSpec)],
257) -> Arc<[SegmentSpec]> {
258    if metadata.is_empty() {
259        return Arc::clone(segments);
260    }
261    segments
262        .iter()
263        .copied()
264        .chain(metadata.iter().map(|(_, segment)| *segment))
265        .collect()
266}
267
268fn segments_end(segments: &[SegmentSpec]) -> Option<u64> {
269    segments.iter().try_fold(0u64, |end, segment| {
270        Some(end.max(segment.offset.checked_add(u64::from(segment.length))?))
271    })
272}
273
274/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
275///
276/// A corrupt or malicious file can declare a segment whose offset or length extends past the end
277/// of the file. Rejecting such files up front ensures that later slicing of the backing buffer
278/// returns a [`VortexError`](vortex_error::VortexError) rather than panicking (see issue #8819).
279fn validate_segments_within_file(segments: &[SegmentSpec], file_size: u64) -> VortexResult<()> {
280    for segment in segments {
281        let within_file = segment
282            .offset
283            .checked_add(segment.length as u64)
284            .is_some_and(|end| end <= file_size);
285        if !within_file {
286            vortex_bail!(
287                "Segment at offset {} with length {} extends past the end of the \
288                 {file_size}-byte file",
289                segment.offset,
290                segment.length,
291            );
292        }
293    }
294    Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299    use vortex_buffer::Alignment;
300
301    use super::*;
302
303    fn segment(offset: u64, length: u32) -> SegmentSpec {
304        SegmentSpec {
305            offset,
306            length,
307            alignment: Alignment::none(),
308        }
309    }
310
311    #[test]
312    fn accepts_segments_within_file() -> VortexResult<()> {
313        validate_segments_within_file(&[segment(0, 100), segment(100, 50)], 150)?;
314        Ok(())
315    }
316
317    #[test]
318    fn rejects_segment_extending_past_end_of_file() {
319        let err =
320            validate_segments_within_file(&[segment(0, 100), segment(100, 51)], 150).unwrap_err();
321        assert!(err.to_string().contains("past the end"), "{err}");
322    }
323
324    #[test]
325    fn rejects_segment_offset_length_overflow() {
326        let err = validate_segments_within_file(&[segment(u64::MAX, 1)], u64::MAX).unwrap_err();
327        assert!(err.to_string().contains("past the end"), "{err}");
328    }
329
330    #[test]
331    fn segments_end_covers_overlapping_segments() {
332        // Valid files never overlap segments, but a malformed footer may declare an earlier
333        // segment that ends after a later one, and the end must cover it so that file size
334        // validation still rejects it.
335        assert_eq!(
336            segments_end(&[segment(0, 300), segment(100, 50)]),
337            Some(300)
338        );
339        assert_eq!(segments_end(&[]), Some(0));
340        assert_eq!(segments_end(&[segment(0, 1), segment(u64::MAX, 1)]), None);
341    }
342
343    #[test]
344    fn segments_with_metadata_appends_metadata_segments() {
345        let segments: Arc<[SegmentSpec]> = Arc::from([segment(0, 10), segment(10, 20)]);
346
347        let without = segments_with_metadata(&segments, &[]);
348        assert!(Arc::ptr_eq(&without, &segments));
349
350        let with = segments_with_metadata(&segments, &[("key".to_string(), segment(30, 5))]);
351        let spans: Vec<_> = with.iter().map(|s| (s.offset, s.length)).collect();
352        assert_eq!(spans, [(0, 10), (10, 20), (30, 5)]);
353    }
354}