iris_source/window.rs
1//! A file view that slides inside a fixed reservation of address space.
2//!
3//! # What this is for
4//!
5//! A decoder in the sandbox asks for byte ranges. The host has to put those bytes at an address the
6//! decoder can read, and the obvious way to do that is to map the whole file, which is what the
7//! prior art does. Mapping the whole file is fast and it costs address space proportional to the
8//! dataset, which is fine until the dataset is larger than the address space or until there are a
9//! thousand of them open at once.
10//!
11//! A window is the other trade. It reserves a fixed span of addresses once, maps a piece of the file
12//! into it, and moves that piece when a request falls outside it. The address space cost is constant
13//! and chosen by the host rather than by the data. The cost is that a request which does not fall in
14//! the current view pays for a remap, so the win depends entirely on requests being clustered, which
15//! for a columnar scan they are.
16//!
17//! # The property that matters
18//!
19//! When the view moves, everything the old view pointed at has to stop being readable. Not stop
20//! being correct, stop being readable. A decoder that reads through a stale pointer and gets bytes
21//! from the part of the file that used to be there produces an answer that is wrong and looks right,
22//! and there is nothing downstream that can catch it. So the vacated range does not become free
23//! memory and does not become zeroes: it goes back to being reserved and unreadable, and a read
24//! through a stale pointer faults.
25//!
26//! Rust's own rules cover the safe path already, because [`Window::range`] borrows the window
27//! mutably and hands back a slice tied to that borrow, so a slice cannot outlive the view it came
28//! from. That is not the case the gate is about. The case the gate is about is a raw address that
29//! crossed into a sandbox, where the borrow checker is not present, and the test for it is in
30//! `tests/window.rs`.
31//!
32//! # Alignment
33//!
34//! Offsets and lengths are rounded by two different numbers, which is a Windows distinction that
35//! Unix does not have and is the easiest thing here to get wrong on a machine where they happen to
36//! be equal. A mapping offset has to be a multiple of the allocation granularity, sixty four
37//! kibibytes on Windows and the page size everywhere else. A mapping length has to be a multiple of
38//! the page size. Rounding a length up by the allocation granularity instead looks correct on Unix
39//! and fails on Windows at the end of a file, because the pages past the end of the section are not
40//! part of it.
41//!
42//! A view is therefore rounded out at both ends and is usually larger than what was asked for. The
43//! slice handed back is not: it is exactly the requested range, cut out of the middle.
44
45use std::fmt;
46use std::fs::File;
47use std::io;
48use std::path::Path;
49
50use crate::sys;
51
52/// How much address space a window reserves when the caller does not say.
53///
54/// Large enough that a column chunk in a scan lands inside it and no remap happens, small enough
55/// that a host can hold many of them. It is a default rather than a tuned number, because the
56/// measurement that would tune it needs a real scan and there is not one yet.
57pub const DEFAULT_SPAN: usize = 4 * 1024 * 1024;
58
59/// What can go wrong when opening a window or moving it.
60#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum WindowError {
63 /// The operating system refused a reservation, a mapping or a file operation.
64 #[error("{operation} failed: {source}")]
65 Os {
66 /// Which step failed, so the error says where in the sequence it stopped.
67 operation: &'static str,
68 /// What the operating system said.
69 #[source]
70 source: io::Error,
71 },
72
73 /// The requested range runs past the end of the file.
74 #[error("bytes {at}..{end} were asked for and the file is {len} bytes long")]
75 OutOfBounds {
76 /// Where the request started.
77 at: u64,
78 /// One past where it ended.
79 end: u64,
80 /// How long the file is.
81 len: u64,
82 },
83
84 /// The request cannot be covered by one view, so this window cannot serve it at all.
85 ///
86 /// This is a configuration error rather than a data error: the window was opened with a span
87 /// smaller than the largest range it was going to be asked for.
88 ///
89 /// `wanted` can be larger than the length that was requested, and the difference is not a
90 /// mistake. A view has to start on an alignment boundary, so a request that starts part of the
91 /// way into one needs the bytes before it mapped as well, and `wanted` is what the mapping has
92 /// to cover rather than what the caller asked to read.
93 #[error("covering that range needs {wanted} bytes in one view and this window reserved {span}")]
94 TooLarge {
95 /// How many bytes a single view would have to cover, including alignment.
96 wanted: usize,
97 /// How many the window can map at once.
98 span: usize,
99 },
100}
101
102type Result<T> = std::result::Result<T, WindowError>;
103
104fn os(operation: &'static str) -> impl FnOnce(io::Error) -> WindowError {
105 move |source| WindowError::Os { operation, source }
106}
107
108/// Where the current view sits. Both fields are already rounded to the platform's alignments.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110struct View {
111 at: u64,
112 len: u64,
113}
114
115impl View {
116 /// Whether the half open range `at..at + len` is entirely inside this view.
117 fn covers(self, at: u64, len: u64) -> bool {
118 let Some(want_end) = at.checked_add(len) else {
119 return false;
120 };
121 let Some(have_end) = self.at.checked_add(self.len) else {
122 return false;
123 };
124 at >= self.at && want_end <= have_end
125 }
126}
127
128/// A read only view of part of a file, at a fixed address, that can be moved.
129///
130/// See the [module documentation](self) for what this is for and what it guarantees. In short: the
131/// address the window lives at is reserved once and held until the window is dropped, and moving the
132/// view makes the bytes that used to be there unreadable rather than stale.
133pub struct Window {
134 // Declaration order is drop order, and these three have to come apart in this order. The
135 // reservation unmaps the view, which has to happen before the section it was mapped from is
136 // closed, which has to happen before the file underneath it is. Both platforms tolerate the
137 // other order, because a mapping keeps its own reference to what it maps, but relying on that
138 // means the code is correct for a reason that is not visible where the code is.
139 reservation: sys::Reservation,
140 /// None for an empty file, which has nothing to map and no section to map it from.
141 backing: Option<sys::Backing>,
142 /// Held because the mapping refers to it. On Unix the mapping was made from a descriptor number
143 /// rather than from a reference, so dropping the file early would close it out from under the
144 /// next slide.
145 file: File,
146 len: u64,
147 view: Option<View>,
148 slides: u64,
149}
150
151impl Window {
152 /// Opens `path` read only and reserves [`DEFAULT_SPAN`] bytes of address space for it.
153 ///
154 /// # Errors
155 ///
156 /// If the file cannot be opened or measured, or the reservation is refused.
157 pub fn open(path: &Path) -> Result<Self> {
158 let file = File::open(path).map_err(os("opening the file"))?;
159 Self::with_span(file, DEFAULT_SPAN)
160 }
161
162 /// Reserves `span` bytes of address space for `file`, rounded up to what the platform allows.
163 ///
164 /// The span bounds the largest range this window can serve in one piece, so it has to be at
165 /// least as large as the largest request plus the alignment slack in front of it. A view starts
166 /// on an allocation boundary, so in the worst case that slack is one whole unit of allocation
167 /// granularity, which is sixty four kibibytes on Windows and the page size elsewhere. A span of
168 /// exactly one unit therefore serves a request only when it happens not to straddle a boundary,
169 /// and a span for a largest request of `n` wants to be at least `n` plus one unit.
170 ///
171 /// A span of zero is rounded up to one unit of allocation granularity rather than rejected,
172 /// because a window over an empty file is a reasonable thing to ask for and reserving nothing is
173 /// not.
174 ///
175 /// # Errors
176 ///
177 /// If the file cannot be measured, or the reservation is refused.
178 pub fn with_span(file: File, span: usize) -> Result<Self> {
179 let len = file.metadata().map_err(os("measuring the file"))?.len();
180
181 let unit = sys::granularity();
182 let span = round_up(span.max(1), unit).ok_or(WindowError::TooLarge {
183 wanted: span,
184 span: usize::MAX,
185 })?;
186
187 let reservation = sys::Reservation::new(span).map_err(os("reserving address space"))?;
188
189 // An empty file has no section to map from. Windows refuses to create one and Unix would
190 // accept it and produce a mapping nothing may read, so neither platform gains anything from
191 // having one, and every path below already handles a window with no view.
192 let backing = if len == 0 {
193 None
194 } else {
195 Some(sys::Backing::new(&file).map_err(os("preparing the file for mapping"))?)
196 };
197
198 Ok(Self {
199 reservation,
200 backing,
201 file,
202 len,
203 view: None,
204 slides: 0,
205 })
206 }
207
208 /// How long the file is.
209 #[must_use]
210 pub fn len(&self) -> u64 {
211 self.len
212 }
213
214 /// Whether the file is empty.
215 #[must_use]
216 pub fn is_empty(&self) -> bool {
217 self.len == 0
218 }
219
220 /// The file this window reads from.
221 #[must_use]
222 pub fn file(&self) -> &File {
223 &self.file
224 }
225
226 /// How much address space this window holds.
227 #[must_use]
228 pub fn span(&self) -> usize {
229 self.reservation.span()
230 }
231
232 /// How many times the view has moved since the window was opened.
233 ///
234 /// A scan whose requests are clustered slides rarely. One that slides on nearly every request is
235 /// either reading in an order the window is the wrong structure for or was opened with too small
236 /// a span, and this is how a host tells those apart from the outside.
237 #[must_use]
238 pub fn slides(&self) -> u64 {
239 self.slides
240 }
241
242 /// The bytes of the file from `at`, `len` of them.
243 ///
244 /// If the range is already inside the current view this costs a comparison. If it is not, the
245 /// view moves first, which unmaps the old one and maps a new one, and every address the old view
246 /// covered stops being readable.
247 ///
248 /// The returned slice borrows the window, so it cannot outlive the view it came from. That is
249 /// the safe half of the guarantee in the [module documentation](self); the other half is about
250 /// raw addresses and is a property of the mapping rather than of this signature.
251 ///
252 /// # Errors
253 ///
254 /// [`WindowError::OutOfBounds`] if the range runs past the end of the file,
255 /// [`WindowError::TooLarge`] if no single view could cover it, and [`WindowError::Os`] if the
256 /// remap is refused.
257 pub fn range(&mut self, at: u64, len: usize) -> Result<&[u8]> {
258 let wide = len as u64;
259 let end = at.saturating_add(wide);
260 if end > self.len {
261 return Err(WindowError::OutOfBounds {
262 at,
263 end,
264 len: self.len,
265 });
266 }
267
268 // What a view has to cover is the request plus however far into an alignment unit it starts,
269 // because a view cannot start anywhere else. A request of exactly the span therefore does
270 // not fit unless it happens to be aligned, which is worth an error that says so rather than
271 // a mapping that quietly comes up short.
272 let unit = sys::granularity() as u64;
273 let skew = usize::try_from(at % unit).unwrap_or(usize::MAX);
274 let needed = skew.saturating_add(len);
275 if needed > self.span() {
276 return Err(WindowError::TooLarge {
277 wanted: needed,
278 span: self.span(),
279 });
280 }
281
282 if !self.view.is_some_and(|view| view.covers(at, wide)) {
283 self.slide_to(at)?;
284 }
285
286 let Some(view) = self.view else {
287 // No view means an empty file, which the bounds check only lets through for a zero
288 // length request. There is nothing mapped and nothing to hand back.
289 debug_assert_eq!(len, 0);
290 return Ok(&[]);
291 };
292
293 // The view starts at or before `at` by construction, so this does not go negative, and it is
294 // smaller than the span, so it fits.
295 let offset = usize::try_from(at - view.at).unwrap_or(0);
296
297 // SAFETY: the view is mapped and readable across view.at..view.at + view.len, which the
298 // check above established contains at..at + len, so offset..offset + len is inside one
299 // mapping. The slice borrows self for its lifetime and nothing can move the view without
300 // &mut self, so the mapping outlives the slice.
301 let bytes = unsafe {
302 std::slice::from_raw_parts(self.reservation.base().as_ptr().add(offset), len)
303 };
304 Ok(bytes)
305 }
306
307 /// Moves the view so that it starts at the alignment boundary at or below `at`.
308 ///
309 /// The view runs from there for as much of the span as the file has left, rounded up to a page
310 /// so that the last view of a file covers the tail. Starting the view at the request rather than
311 /// centred on it is deliberate: a scan reads forwards, so the bytes worth having mapped are the
312 /// ones after the request and not the ones before it.
313 ///
314 /// The caller has already established that the request fits, so this does not check again.
315 fn slide_to(&mut self, at: u64) -> Result<()> {
316 let Some(backing) = self.backing.as_ref() else {
317 return Ok(());
318 };
319
320 let unit = sys::granularity() as u64;
321 let start = at - (at % unit);
322 let remaining = self.len - start;
323
324 let span = self.reservation.span();
325 let available = usize::try_from(remaining).unwrap_or(usize::MAX);
326 // Round up to a page so the last view reaches the end of the file. The bytes between the end
327 // of the file and the end of that page read as zero on both platforms and are never inside a
328 // slice this hands out, because every slice is bounded by the file length.
329 let view_len = round_up(available.min(span), sys::page())
330 .unwrap_or(span)
331 .min(span);
332
333 // A zero length request at exactly the end of the file has nothing after it to map, and a
334 // mapping of no bytes is refused by both platforms with an error that says nothing useful.
335 // The window is left with no view, which is the state it is already in for an empty file and
336 // which the caller path below already handles by handing back an empty slice.
337 //
338 // The fuzzer found this in the first minute it ran, on the input `range(len, 0)`, which is
339 // the shape a decoder produces when it asks for a column that happens to be empty and sits
340 // last in the file. Nothing about it looks like an edge case from inside the arithmetic.
341 if view_len == 0 {
342 self.view = None;
343 return self
344 .reservation
345 .unmap()
346 .map_err(os("unmapping the previous view"));
347 }
348
349 // The old view goes first even though Unix could replace it in one call, so that a failed
350 // map leaves the window with nothing mapped rather than with a view it thinks has moved.
351 // Windows has to do it in this order anyway, and a state machine with one shape on both
352 // platforms is worth one extra call on the platform that could have skipped it.
353 self.view = None;
354 self.reservation
355 .unmap()
356 .map_err(os("unmapping the previous view"))?;
357 self.reservation
358 .map(backing, start, view_len)
359 .map_err(os("mapping the file into the reservation"))?;
360
361 self.view = Some(View {
362 at: start,
363 len: view_len as u64,
364 });
365 self.slides += 1;
366 Ok(())
367 }
368
369 /// The address the reservation starts at.
370 ///
371 /// This is the address a host would hand to a sandbox, and it does not move for the life of the
372 /// window, which is the point of reserving rather than mapping. Reading through it is only
373 /// defined for the part of the reservation the current view covers. It is public so that the
374 /// stale read tests can hold an address across a slide and check that it stopped being readable,
375 /// which is a property no safe signature can express.
376 #[must_use]
377 pub fn address(&self) -> *const u8 {
378 self.reservation.base().as_ptr()
379 }
380
381 /// Where the current view sits in the file and how long it is, or `None` if nothing is mapped.
382 ///
383 /// The length is the mapped length, which is rounded out to a page and so is usually longer than
384 /// the part of the file it covers.
385 #[must_use]
386 pub fn mapped(&self) -> Option<(u64, usize)> {
387 self.view
388 .map(|view| (view.at, usize::try_from(view.len).unwrap_or(usize::MAX)))
389 }
390}
391
392impl fmt::Debug for Window {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 // The reservation, the section and the file are left out on purpose. Their debug output is
395 // a raw address and two handle numbers, which say nothing a reader can use and change on
396 // every run, and this is the type somebody prints when a slide went wrong.
397 f.debug_struct("Window")
398 .field("len", &self.len)
399 .field("span", &self.span())
400 .field("view", &self.view)
401 .field("slides", &self.slides)
402 .finish_non_exhaustive()
403 }
404}
405
406/// `value` rounded up to the next multiple of `unit`, or `None` if that overflows.
407fn round_up(value: usize, unit: usize) -> Option<usize> {
408 debug_assert!(
409 unit != 0,
410 "an alignment of zero is not something a platform reports"
411 );
412 let over = value % unit;
413 if over == 0 {
414 return Some(value);
415 }
416 value.checked_add(unit - over)
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn rounding_up_leaves_exact_multiples_alone() {
425 assert_eq!(round_up(0, 4096), Some(0));
426 assert_eq!(round_up(4096, 4096), Some(4096));
427 assert_eq!(round_up(1, 4096), Some(4096));
428 assert_eq!(round_up(4097, 4096), Some(8192));
429 assert_eq!(round_up(usize::MAX, 4096), None);
430 }
431
432 #[test]
433 fn a_view_covers_a_range_inside_it_and_nothing_else() {
434 let view = View { at: 100, len: 50 };
435 assert!(view.covers(100, 50));
436 assert!(view.covers(120, 10));
437 assert!(view.covers(150, 0));
438 assert!(!view.covers(99, 1));
439 assert!(!view.covers(150, 1));
440 assert!(!view.covers(140, 11));
441 // A length that would overflow the addition is not covered by anything.
442 assert!(!view.covers(u64::MAX, 2));
443 }
444}