Skip to main content

miden_serde_utils/
file.rs

1use std::{
2    fs::{self, File, Metadata},
3    io::{self, Read},
4    path::Path,
5};
6
7/// Reads a regular file containing at most `max_bytes` bytes.
8///
9/// Symlinks to regular files are accepted. Directories, pipes, and devices are rejected before
10/// opening them. On Unix, nonblocking opening also prevents a replacement FIFO from blocking.
11/// The opened file is checked again, and reads are bounded even if it grows after the metadata
12/// check. No allocation is based on the file's reported length.
13///
14/// # Errors
15/// Returns an I/O error if the path cannot be read, is not a regular file, or exceeds the limit.
16pub fn read_file_bounded(path: impl AsRef<Path>, max_bytes: usize) -> io::Result<Vec<u8>> {
17    let path = path.as_ref();
18    check_metadata(&fs::metadata(path)?, max_bytes)?;
19    read_file_after_metadata(path, max_bytes)
20}
21
22fn read_file_after_metadata(path: &Path, max_bytes: usize) -> io::Result<Vec<u8>> {
23    #[cfg(unix)]
24    let file = {
25        use rustix::fs::{Mode, OFlags, open};
26
27        File::from(open(path, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty())?)
28    };
29    #[cfg(not(unix))]
30    let file = File::open(path)?;
31    check_metadata(&file.metadata()?, max_bytes)?;
32    read_bounded(file, max_bytes)
33}
34
35fn check_metadata(metadata: &Metadata, max_bytes: usize) -> io::Result<()> {
36    if !metadata.is_file() {
37        return Err(io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"));
38    }
39    if metadata.len() > max_bytes as u64 {
40        return Err(size_error(max_bytes));
41    }
42    Ok(())
43}
44
45fn read_bounded(mut reader: impl Read, max_bytes: usize) -> io::Result<Vec<u8>> {
46    let mut bytes = Vec::new();
47    reader.by_ref().take(max_bytes as u64).read_to_end(&mut bytes)?;
48    match reader.read_exact(&mut [0]) {
49        Ok(()) => Err(size_error(max_bytes)),
50        Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => Ok(bytes),
51        Err(err) => Err(err),
52    }
53}
54
55fn size_error(max_bytes: usize) -> io::Error {
56    io::Error::new(
57        io::ErrorKind::InvalidData,
58        format!("file exceeds size limit of {max_bytes} bytes"),
59    )
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn bounded_read_accepts_the_limit_and_rejects_an_extra_byte() {
68        assert_eq!(read_bounded(&b"abc"[..], 3).unwrap(), b"abc");
69        assert_eq!(read_bounded(&b"ab"[..], 3).unwrap(), b"ab");
70        let err = read_bounded(&b"abcd"[..], 3).unwrap_err();
71        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
72        assert_eq!(err.to_string(), "file exceeds size limit of 3 bytes");
73        assert!(read_bounded(&b""[..], 0).unwrap().is_empty());
74        let err = read_bounded(&b"a"[..], 0).unwrap_err();
75        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
76        assert_eq!(err.to_string(), "file exceeds size limit of 0 bytes");
77    }
78
79    #[test]
80    fn bounded_read_stops_after_the_limit_and_one_probe_byte() {
81        let mut source = io::Cursor::new(b"abcdef");
82        assert!(read_bounded(&mut source, 3).is_err());
83        assert_eq!(source.position(), 4);
84    }
85
86    #[test]
87    fn bounded_read_retries_an_interrupted_overflow_probe() {
88        struct InterruptedProbe<'a> {
89            remaining: &'a [u8],
90            interrupted: bool,
91        }
92
93        impl Read for InterruptedProbe<'_> {
94            fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
95                if !self.interrupted {
96                    self.interrupted = true;
97                    return Err(io::ErrorKind::Interrupted.into());
98                }
99                self.remaining.read(buf)
100            }
101        }
102
103        for remaining in [b"".as_slice(), b"d".as_slice()] {
104            let mut source =
105                io::Cursor::new(b"abc").chain(InterruptedProbe { remaining, interrupted: false });
106            let result = read_bounded(&mut source, 3);
107            if remaining.is_empty() {
108                assert_eq!(result.unwrap(), b"abc");
109            } else {
110                let err = result.unwrap_err();
111                assert_eq!(err.kind(), io::ErrorKind::InvalidData);
112                assert_eq!(err.to_string(), "file exceeds size limit of 3 bytes");
113            }
114        }
115    }
116
117    #[test]
118    fn bounded_file_rejects_directories() {
119        assert_eq!(
120            read_file_bounded(std::env::temp_dir(), 3).unwrap_err().kind(),
121            io::ErrorKind::InvalidInput
122        );
123    }
124
125    #[cfg(unix)]
126    #[test]
127    fn bounded_file_rejects_devices() {
128        assert_eq!(
129            read_file_bounded("/dev/zero", 3).unwrap_err().kind(),
130            io::ErrorKind::InvalidInput
131        );
132    }
133
134    #[cfg(unix)]
135    #[test]
136    fn bounded_file_rejects_fifo_replacing_a_regular_file_without_blocking() {
137        use std::{
138            process::Command,
139            thread,
140            time::{Duration, Instant},
141        };
142
143        const CHILD_PATH: &str = "MIDEN_TEST_REPLACED_FIFO_PATH";
144        if let Some(path) = std::env::var_os(CHILD_PATH) {
145            // Resume at the open after the parent checked the original regular file's metadata.
146            let err = read_file_after_metadata(Path::new(&path), 3).unwrap_err();
147            assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
148            assert_eq!(err.to_string(), "expected a regular file");
149            return;
150        }
151
152        let dir = tempfile::tempdir().unwrap();
153        let path = dir.path().join("replaced");
154        fs::write(&path, b"abc").unwrap();
155        check_metadata(&fs::metadata(&path).unwrap(), 3).unwrap();
156        fs::remove_file(&path).unwrap();
157        // Darwin does not provide mkfifoat; the POSIX utility also works on that platform.
158        assert!(
159            Command::new("mkfifo")
160                .args(["-m", "600"])
161                .arg(&path)
162                .status()
163                .unwrap()
164                .success()
165        );
166
167        // Isolate the open in a child so a regression cannot hang this test indefinitely.
168        let mut child = Command::new(std::env::current_exe().unwrap())
169            .args([
170                "--exact",
171                "file::tests::bounded_file_rejects_fifo_replacing_a_regular_file_without_blocking",
172            ])
173            .env(CHILD_PATH, &path)
174            .spawn()
175            .unwrap();
176        let deadline = Instant::now() + Duration::from_secs(10);
177        loop {
178            if let Some(status) = child.try_wait().unwrap() {
179                assert!(status.success(), "replacement FIFO child failed: {status}");
180                break;
181            }
182            if Instant::now() >= deadline {
183                child.kill().unwrap();
184                child.wait().unwrap();
185                panic!("opening a replacement FIFO blocked");
186            }
187            thread::sleep(Duration::from_millis(10));
188        }
189    }
190}