Skip to main content

kaish_vfs/
dev.rs

1//! Synthetic device filesystem.
2//!
3//! Mounted at `/dev` in the hermetic VFS modes (sandboxed / no-local) where the
4//! host's real `/dev` is not reachable. It provides software implementations of
5//! the handful of character devices shell scripts actually lean on:
6//!
7//! - `/dev/null` — a sink: writes are discarded, reads return empty.
8//! - `/dev/zero` — an endless stream of zero bytes.
9//! - `/dev/urandom`, `/dev/random` — endless cryptographic random bytes from
10//!   the OS CSPRNG (`getrandom`). Both alias the same non-blocking source.
11//!
12//! The endless devices (`zero`, `urandom`, `random`) have no whole-device read:
13//! kaish reads whole files into memory, so `cat /dev/urandom` is a loud error.
14//! A counted read — `head -c N`, `dd … count=…` — yields exactly the requested
15//! bytes via [`Filesystem::read_range`]. Raw random bytes only become *useful*
16//! through a binary-aware tool (`dd`) since kaish pipes are UTF-8 text; see
17//! `docs/binary-data.md`.
18
19use crate::traits::{DirEntry, DirEntryKind, Filesystem, ReadRange};
20use async_trait::async_trait;
21use std::io;
22use std::path::Path;
23
24/// Upper bound on a single counted device read. A `head -c N /dev/zero` for an
25/// absurd `N` would otherwise try to allocate `N` bytes up front and wedge the
26/// kernel; we refuse loudly instead of OOMing.
27const MAX_DEVICE_READ_BYTES: u64 = 64 * 1024 * 1024;
28
29/// The synthetic `/dev`.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct DevFs;
32
33/// A device this filesystem knows how to synthesize.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Device {
36    /// Discards writes, reads empty.
37    Null,
38    /// Endless zero bytes; only counted reads are answerable.
39    Zero,
40    /// Endless cryptographic random bytes (OS CSPRNG via `getrandom`).
41    /// `/dev/urandom` and `/dev/random` both map here.
42    Random,
43}
44
45impl Device {
46    /// The device's source word, for error messages.
47    fn name(self) -> &'static str {
48        match self {
49            Device::Null => "null",
50            Device::Zero => "zero",
51            Device::Random => "urandom",
52        }
53    }
54}
55
56impl DevFs {
57    /// Create a new synthetic device filesystem.
58    pub fn new() -> Self {
59        Self
60    }
61
62    /// Names of the devices exposed under this mount, for directory listing.
63    const NAMES: [&'static str; 4] = ["null", "random", "urandom", "zero"];
64
65    /// Resolve a mount-relative path to a known device. Paths arrive with the
66    /// `/dev` prefix already stripped by the router, so we see `null`/`zero`/…
67    fn device(path: &Path) -> Option<Device> {
68        match path.to_str()?.trim_start_matches('/') {
69            "null" => Some(Device::Null),
70            "zero" => Some(Device::Zero),
71            "urandom" | "random" => Some(Device::Random),
72            _ => None,
73        }
74    }
75
76    /// True when the path refers to the mount root itself.
77    fn is_root(path: &Path) -> bool {
78        let s = path.to_string_lossy();
79        let trimmed = s.trim_matches('/');
80        trimmed.is_empty() || trimmed == "."
81    }
82
83    fn not_found(path: &Path) -> io::Error {
84        io::Error::new(
85            io::ErrorKind::NotFound,
86            format!("no such device: /dev/{}", path.display()),
87        )
88    }
89
90    /// The error for asking an infinite device for "everything". Names the fix.
91    fn unbounded(name: &str) -> io::Error {
92        io::Error::new(
93            io::ErrorKind::InvalidInput,
94            format!(
95                "/dev/{name} is an endless device; reading the whole of it is unbounded. \
96                 Read a fixed number of bytes instead, e.g. `head -c 32 /dev/{name}`"
97            ),
98        )
99    }
100
101    fn entry(name: &str) -> DirEntry {
102        DirEntry {
103            name: name.to_string(),
104            kind: DirEntryKind::File,
105            size: 0,
106            modified: None,
107            permissions: None,
108            symlink_target: None,
109        }
110    }
111}
112
113#[async_trait]
114impl Filesystem for DevFs {
115    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
116        match Self::device(path) {
117            Some(Device::Null) => Ok(Vec::new()),
118            Some(dev) => Err(Self::unbounded(dev.name())), // endless: no whole read
119            None => Err(Self::not_found(path)),
120        }
121    }
122
123    async fn read_range(&self, path: &Path, range: Option<ReadRange>) -> io::Result<Vec<u8>> {
124        let Some(dev) = Self::device(path) else {
125            return Err(Self::not_found(path));
126        };
127        // The sink ignores any range — it is always empty.
128        if dev == Device::Null {
129            return Ok(Vec::new());
130        }
131        // Endless stream: only a byte count is answerable. A None range or a
132        // line-only range is the unbounded "give me everything" ask.
133        let limit = match range.and_then(|r| r.limit) {
134            Some(n) => n,
135            None => return Err(Self::unbounded(dev.name())),
136        };
137        if limit > MAX_DEVICE_READ_BYTES {
138            return Err(io::Error::new(
139                io::ErrorKind::InvalidInput,
140                format!(
141                    "requested {limit} bytes from /dev/{} exceeds the device read cap \
142                     of {MAX_DEVICE_READ_BYTES} bytes",
143                    dev.name()
144                ),
145            ));
146        }
147        let mut buf = vec![0u8; limit as usize];
148        if dev == Device::Random {
149            getrandom::fill(&mut buf).map_err(|e| {
150                io::Error::other(format!("/dev/{}: entropy source failed: {e}", dev.name()))
151            })?;
152        }
153        Ok(buf) // Device::Zero leaves the buffer zeroed
154    }
155
156    async fn write(&self, path: &Path, _data: &[u8]) -> io::Result<()> {
157        // Every device accepts and discards writes — `cmd > /dev/null` is the
158        // whole point. Writing to an unknown device is still an error.
159        match Self::device(path) {
160            Some(_) => Ok(()),
161            None => Err(Self::not_found(path)),
162        }
163    }
164
165    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
166        if Self::is_root(path) {
167            return Ok(Self::NAMES.iter().map(|n| Self::entry(n)).collect());
168        }
169        if Self::device(path).is_some() {
170            return Err(io::Error::new(
171                io::ErrorKind::NotADirectory,
172                format!("not a directory: /dev/{}", path.display()),
173            ));
174        }
175        Err(Self::not_found(path))
176    }
177
178    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
179        if Self::is_root(path) {
180            return Ok(DirEntry {
181                name: "dev".to_string(),
182                kind: DirEntryKind::Directory,
183                size: 0,
184                modified: None,
185                permissions: None,
186                symlink_target: None,
187            });
188        }
189        match Self::device(path) {
190            Some(_) => Ok(Self::entry(
191                path.to_str().unwrap_or_default().trim_start_matches('/'),
192            )),
193            None => Err(Self::not_found(path)),
194        }
195    }
196
197    async fn mkdir(&self, path: &Path) -> io::Result<()> {
198        Err(io::Error::new(
199            io::ErrorKind::PermissionDenied,
200            format!("/dev is read-only: cannot create {}", path.display()),
201        ))
202    }
203
204    async fn remove(&self, path: &Path) -> io::Result<()> {
205        Err(io::Error::new(
206            io::ErrorKind::PermissionDenied,
207            format!("/dev is read-only: cannot remove {}", path.display()),
208        ))
209    }
210
211    fn read_only(&self) -> bool {
212        // Writes to the devices "succeed" (they discard), so the mount is not
213        // read-only in the sense the router cares about — refusing a write
214        // would break `> /dev/null`.
215        false
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[tokio::test]
224    async fn null_reads_empty() {
225        let fs = DevFs::new();
226        assert_eq!(fs.read(Path::new("null")).await.unwrap(), b"");
227        // A counted read of null is still empty.
228        assert_eq!(
229            fs.read_range(Path::new("null"), Some(ReadRange::bytes(0, 16)))
230                .await
231                .unwrap(),
232            b""
233        );
234    }
235
236    #[tokio::test]
237    async fn null_discards_writes() {
238        let fs = DevFs::new();
239        fs.write(Path::new("null"), b"anything at all").await.unwrap();
240    }
241
242    #[tokio::test]
243    async fn zero_counted_read_yields_zeros() {
244        let fs = DevFs::new();
245        let out = fs
246            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, 8)))
247            .await
248            .unwrap();
249        assert_eq!(out, vec![0u8; 8]);
250    }
251
252    #[tokio::test]
253    async fn zero_whole_read_is_loud_error() {
254        let fs = DevFs::new();
255        let err = fs.read(Path::new("zero")).await.unwrap_err();
256        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
257        assert!(err.to_string().contains("head -c"), "should name the fix: {err}");
258
259        // A None range through read_range is the same unbounded ask.
260        let err = fs.read_range(Path::new("zero"), None).await.unwrap_err();
261        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
262    }
263
264    #[tokio::test]
265    async fn zero_read_cap_is_enforced() {
266        let fs = DevFs::new();
267        let err = fs
268            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, MAX_DEVICE_READ_BYTES + 1)))
269            .await
270            .unwrap_err();
271        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
272        assert!(err.to_string().contains("cap"), "should mention the cap: {err}");
273    }
274
275    #[tokio::test]
276    async fn urandom_counted_read_is_random_and_sized() {
277        let fs = DevFs::new();
278        let a = fs
279            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
280            .await
281            .unwrap();
282        assert_eq!(a.len(), 32, "exact byte count");
283        // Two draws of 32 bytes are astronomically unlikely to match.
284        let b = fs
285            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
286            .await
287            .unwrap();
288        assert_ne!(a, b, "entropy: two draws must differ");
289        // `random` aliases the same source.
290        let c = fs
291            .read_range(Path::new("random"), Some(ReadRange::bytes(0, 8)))
292            .await
293            .unwrap();
294        assert_eq!(c.len(), 8);
295    }
296
297    #[tokio::test]
298    async fn urandom_whole_read_is_loud_error() {
299        let fs = DevFs::new();
300        let err = fs.read(Path::new("urandom")).await.unwrap_err();
301        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
302        assert!(err.to_string().contains("head -c"), "names the fix: {err}");
303    }
304
305    #[tokio::test]
306    async fn unknown_device_is_not_found() {
307        let fs = DevFs::new();
308        assert_eq!(
309            fs.read(Path::new("sda")).await.unwrap_err().kind(),
310            io::ErrorKind::NotFound
311        );
312        assert_eq!(
313            fs.write(Path::new("sda"), b"x").await.unwrap_err().kind(),
314            io::ErrorKind::NotFound
315        );
316    }
317
318    #[tokio::test]
319    async fn list_shows_devices() {
320        let fs = DevFs::new();
321        let names: Vec<_> = fs
322            .list(Path::new(""))
323            .await
324            .unwrap()
325            .into_iter()
326            .map(|e| e.name)
327            .collect();
328        assert_eq!(
329            names,
330            vec![
331                "null".to_string(),
332                "random".to_string(),
333                "urandom".to_string(),
334                "zero".to_string()
335            ]
336        );
337    }
338
339    #[tokio::test]
340    async fn stat_devices_and_root() {
341        let fs = DevFs::new();
342        assert_eq!(fs.stat(Path::new("")).await.unwrap().kind, DirEntryKind::Directory);
343        for dev in ["null", "zero", "urandom", "random"] {
344            let e = fs.stat(Path::new(dev)).await.unwrap();
345            assert_eq!(e.kind, DirEntryKind::File, "{dev}");
346            assert_eq!(e.name, dev, "stat names the device");
347        }
348        assert_eq!(
349            fs.stat(Path::new("nope")).await.unwrap_err().kind(),
350            io::ErrorKind::NotFound
351        );
352    }
353}