cuttlefish_host/handles.rs
1//! Files the host holds open on a guest's behalf.
2//!
3//! This is the host side of the rule that bulk data never enters guest memory. A
4//! block receives a handle and a length, then pulls bounded windows; the host
5//! seeks and reads each window straight off disk. Neither side ever holds the
6//! whole file, so guest memory tracks the window size a block chose rather than
7//! the size of its input.
8
9use cuttlefish_abi::MediaKind;
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::{Read, Seek, SeekFrom};
13use std::path::Path;
14
15/// What a handle refers to.
16///
17/// Rendered pages have no file behind them, so a handle is either something on
18/// disk or bytes the host produced. Both answer the same commands, which is what
19/// lets a rendered page be used anywhere a file-backed image can.
20enum Source {
21 /// Read from disk on demand, so a large file is never resident.
22 File(File),
23 /// Held in memory — a rasterized page, which exists nowhere else.
24 Memory(Vec<u8>),
25}
26
27/// One thing held open for a job.
28struct OpenFile {
29 source: Source,
30 len: u64,
31 kind: MediaKind,
32}
33
34/// A job's open files.
35///
36/// Scoping this to a single job is a security property rather than tidiness.
37/// Because the table lives and dies with one job, a handle from another job
38/// names nothing here — which is what lets [`Handles::slice`] skip a capability
39/// check entirely. The check happened once, at [`Handles::open`], and a handle
40/// cannot be forged into a reference to someone else's data.
41#[derive(Default)]
42pub struct Handles {
43 next: u32,
44 open: HashMap<u32, OpenFile>,
45}
46
47/// Why a handle operation failed.
48#[derive(Debug, thiserror::Error)]
49pub enum HandleError {
50 /// The handle does not belong to this job, or never existed.
51 #[error("no such handle: {0}")]
52 BadHandle(u32),
53 /// The requested offset is beyond the end of the file.
54 #[error("offset {offset} is past end of file ({len} bytes)")]
55 OffsetPastEnd {
56 /// The offset that was asked for.
57 offset: u64,
58 /// The file's actual length.
59 len: u64,
60 },
61 /// The window was too small to contain even one whole character.
62 #[error("window of {0} bytes is too small to hold one character")]
63 WindowTooSmall(u64),
64 /// Underlying I/O failure.
65 #[error(transparent)]
66 Io(#[from] std::io::Error),
67}
68
69/// One window of a file.
70pub struct Window {
71 /// The window's contents.
72 pub text: String,
73 /// Where the returned text actually ended; see [`Handles::slice`].
74 pub next_offset: u64,
75}
76
77impl Handles {
78 /// Open a file, returning its handle, length, and what the host made of it.
79 ///
80 /// The caller is responsible for having capability-checked `path` first —
81 /// this type deliberately knows nothing about capabilities, so that the
82 /// check lives in exactly one place rather than being half-enforced here.
83 pub fn open(&mut self, path: &Path) -> Result<(u32, u64, MediaKind), HandleError> {
84 let mut file = File::open(path)?;
85 let len = file.metadata()?.len();
86
87 // Sniff content rather than trusting the extension: a `.txt` holding a
88 // PNG is a file a block should be told about, and an extension is
89 // whatever the last program to touch the file decided.
90 let mut head = vec![0u8; 4096.min(len as usize)];
91 file.read_exact(&mut head)?;
92 file.seek(SeekFrom::Start(0))?;
93 let kind = classify(&head, len);
94
95 let handle = self.next;
96 self.next += 1;
97 self.open.insert(
98 handle,
99 OpenFile {
100 source: Source::File(file),
101 len,
102 kind: kind.clone(),
103 },
104 );
105 Ok((handle, len, kind))
106 }
107
108 /// Register bytes the host produced — a rendered page — as a new handle.
109 pub fn insert_bytes(&mut self, bytes: Vec<u8>, kind: MediaKind) -> (u32, u64) {
110 let len = bytes.len() as u64;
111 let handle = self.next;
112 self.next += 1;
113 self.open.insert(
114 handle,
115 OpenFile {
116 source: Source::Memory(bytes),
117 len,
118 kind,
119 },
120 );
121 (handle, len)
122 }
123
124 /// What a handle refers to.
125 pub fn kind(&self, handle: u32) -> Result<MediaKind, HandleError> {
126 self.open
127 .get(&handle)
128 .map(|f| f.kind.clone())
129 .ok_or(HandleError::BadHandle(handle))
130 }
131
132 /// Every byte behind a handle.
133 ///
134 /// Used for images headed to a vision model, where the whole thing has to be
135 /// sent. Deliberately host-side only — this is the one place a whole file is
136 /// materialized, and it never crosses into guest memory.
137 pub fn read_all(&mut self, handle: u32) -> Result<Vec<u8>, HandleError> {
138 let f = self
139 .open
140 .get_mut(&handle)
141 .ok_or(HandleError::BadHandle(handle))?;
142 match &mut f.source {
143 Source::Memory(bytes) => Ok(bytes.clone()),
144 Source::File(file) => {
145 let mut buf = Vec::with_capacity(f.len as usize);
146 file.seek(SeekFrom::Start(0))?;
147 file.read_to_end(&mut buf)?;
148 Ok(buf)
149 }
150 }
151 }
152
153 /// Read one window as raw bytes, with no character-boundary handling.
154 pub fn slice_bytes(
155 &mut self,
156 handle: u32,
157 offset: u64,
158 len: u64,
159 ) -> Result<(Vec<u8>, u64), HandleError> {
160 let f = self
161 .open
162 .get_mut(&handle)
163 .ok_or(HandleError::BadHandle(handle))?;
164 if offset > f.len {
165 return Err(HandleError::OffsetPastEnd { offset, len: f.len });
166 }
167
168 let want = len.min(f.len - offset) as usize;
169 let mut buf = vec![0u8; want];
170 match &mut f.source {
171 Source::Memory(bytes) => {
172 buf.copy_from_slice(&bytes[offset as usize..offset as usize + want]);
173 }
174 Source::File(file) => {
175 file.seek(SeekFrom::Start(offset))?;
176 file.read_exact(&mut buf)?;
177 }
178 }
179 Ok((buf, offset + want as u64))
180 }
181
182 /// Read one window, truncated to a UTF-8 character boundary.
183 ///
184 /// The truncation is the subtle part, and the reason [`Window::next_offset`]
185 /// exists at all. A caller walking a file picks window sizes with no idea
186 /// where characters begin, so a naive read splits a multi-byte character at
187 /// nearly every seam and yields mojibake. Instead the window is cut back to
188 /// the last complete character and `next_offset` reports where that landed —
189 /// so a caller resuming from `next_offset`, rather than advancing by the
190 /// length it requested, never observes a split.
191 ///
192 /// Reading past the end is not an error: the window is clamped, because a
193 /// block asking for a full window at the tail of a file is behaving
194 /// correctly. Starting past the end *is* an error, since that indicates the
195 /// caller has lost track of where it is.
196 pub fn slice(&mut self, handle: u32, offset: u64, len: u64) -> Result<Window, HandleError> {
197 let f = self
198 .open
199 .get_mut(&handle)
200 .ok_or(HandleError::BadHandle(handle))?;
201
202 if offset > f.len {
203 return Err(HandleError::OffsetPastEnd { offset, len: f.len });
204 }
205
206 let want = len.min(f.len - offset) as usize;
207 let mut buf = vec![0u8; want];
208 match &mut f.source {
209 Source::Memory(bytes) => {
210 buf.copy_from_slice(&bytes[offset as usize..offset as usize + want]);
211 }
212 Source::File(file) => {
213 file.seek(SeekFrom::Start(offset))?;
214 file.read_exact(&mut buf)?;
215 }
216 }
217
218 let valid = match std::str::from_utf8(&buf) {
219 Ok(_) => buf.len(),
220 Err(e) => e.valid_up_to(),
221 };
222
223 // A window landing entirely inside one character would otherwise return
224 // empty forever, and a caller looping until it reaches the end would
225 // spin making no progress and reporting no problem. Failing is strictly
226 // better than that silence.
227 if valid == 0 && !buf.is_empty() {
228 return Err(HandleError::WindowTooSmall(len));
229 }
230 buf.truncate(valid);
231
232 Ok(Window {
233 text: String::from_utf8(buf).expect("truncated at a validated boundary"),
234 next_offset: offset + valid as u64,
235 })
236 }
237}
238
239/// Work out what a file holds from its leading bytes.
240///
241/// Magic numbers, not extensions. An extension records whatever the last program
242/// to touch the file believed; the bytes record what it is.
243fn classify(head: &[u8], len: u64) -> MediaKind {
244 if head.starts_with(b"%PDF-") {
245 // Page count and text-layer detection need the whole file, so they are
246 // filled in by the document layer; this is the fallback when it cannot.
247 return MediaKind::Document {
248 pages: 0,
249 has_text_layer: false,
250 };
251 }
252 if head.starts_with(&[0x89, b'P', b'N', b'G']) {
253 return MediaKind::Image {
254 format: "png".into(),
255 };
256 }
257 if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
258 return MediaKind::Image {
259 format: "jpeg".into(),
260 };
261 }
262 if head.starts_with(b"GIF8") {
263 return MediaKind::Image {
264 format: "gif".into(),
265 };
266 }
267 if head.len() >= 12 && head.starts_with(b"RIFF") && &head[8..12] == b"WEBP" {
268 return MediaKind::Image {
269 format: "webp".into(),
270 };
271 }
272
273 // An empty file is text: there is nothing in it to be anything else, and
274 // calling it binary would make a block reach for the wrong commands.
275 if len == 0 {
276 return MediaKind::Text;
277 }
278
279 // Valid UTF-8 in the first window is good evidence of text. It can be wrong
280 // for a binary file that happens to begin with valid UTF-8, which is why
281 // `Slice` still fails cleanly on bytes it cannot decode rather than trusting
282 // this.
283 match std::str::from_utf8(head) {
284 Ok(_) => MediaKind::Text,
285 // A trailing partial character means the window cut a multi-byte
286 // sequence, which is what text looks like — not a reason to call it
287 // binary.
288 Err(e) if e.error_len().is_none() && e.valid_up_to() > 0 => MediaKind::Text,
289 Err(_) => MediaKind::Binary,
290 }
291}