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 append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
166        // The trait default reads before writing, but `read` on every device
167        // except /dev/null errors "unbounded" (see above) — `tee -a >
168        // /dev/zero` would fail loudly for the wrong reason. Append has the
169        // same discard-or-not-found contract as write, so delegate directly
170        // instead of reading first.
171        self.write(path, data).await
172    }
173
174    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
175        if Self::is_root(path) {
176            return Ok(Self::NAMES.iter().map(|n| Self::entry(n)).collect());
177        }
178        if Self::device(path).is_some() {
179            return Err(io::Error::new(
180                io::ErrorKind::NotADirectory,
181                format!("not a directory: /dev/{}", path.display()),
182            ));
183        }
184        Err(Self::not_found(path))
185    }
186
187    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
188        if Self::is_root(path) {
189            return Ok(DirEntry {
190                name: "dev".to_string(),
191                kind: DirEntryKind::Directory,
192                size: 0,
193                modified: None,
194                permissions: None,
195                symlink_target: None,
196            });
197        }
198        match Self::device(path) {
199            Some(_) => Ok(Self::entry(
200                path.to_str().unwrap_or_default().trim_start_matches('/'),
201            )),
202            None => Err(Self::not_found(path)),
203        }
204    }
205
206    async fn mkdir(&self, path: &Path) -> io::Result<()> {
207        Err(io::Error::new(
208            io::ErrorKind::PermissionDenied,
209            format!("/dev is read-only: cannot create {}", path.display()),
210        ))
211    }
212
213    async fn remove(&self, path: &Path) -> io::Result<()> {
214        Err(io::Error::new(
215            io::ErrorKind::PermissionDenied,
216            format!("/dev is read-only: cannot remove {}", path.display()),
217        ))
218    }
219
220    fn read_only(&self) -> bool {
221        // Writes to the devices "succeed" (they discard), so the mount is not
222        // read-only in the sense the router cares about — refusing a write
223        // would break `> /dev/null`.
224        false
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[tokio::test]
233    async fn null_reads_empty() {
234        let fs = DevFs::new();
235        assert_eq!(fs.read(Path::new("null")).await.unwrap(), b"");
236        // A counted read of null is still empty.
237        assert_eq!(
238            fs.read_range(Path::new("null"), Some(ReadRange::bytes(0, 16)))
239                .await
240                .unwrap(),
241            b""
242        );
243    }
244
245    #[tokio::test]
246    async fn null_discards_writes() {
247        let fs = DevFs::new();
248        fs.write(Path::new("null"), b"anything at all").await.unwrap();
249    }
250
251    #[tokio::test]
252    async fn zero_counted_read_yields_zeros() {
253        let fs = DevFs::new();
254        let out = fs
255            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, 8)))
256            .await
257            .unwrap();
258        assert_eq!(out, vec![0u8; 8]);
259    }
260
261    #[tokio::test]
262    async fn zero_whole_read_is_loud_error() {
263        let fs = DevFs::new();
264        let err = fs.read(Path::new("zero")).await.unwrap_err();
265        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
266        assert!(err.to_string().contains("head -c"), "should name the fix: {err}");
267
268        // A None range through read_range is the same unbounded ask.
269        let err = fs.read_range(Path::new("zero"), None).await.unwrap_err();
270        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
271    }
272
273    #[tokio::test]
274    async fn zero_read_cap_is_enforced() {
275        let fs = DevFs::new();
276        let err = fs
277            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, MAX_DEVICE_READ_BYTES + 1)))
278            .await
279            .unwrap_err();
280        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
281        assert!(err.to_string().contains("cap"), "should mention the cap: {err}");
282    }
283
284    #[tokio::test]
285    async fn urandom_counted_read_is_random_and_sized() {
286        let fs = DevFs::new();
287        let a = fs
288            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
289            .await
290            .unwrap();
291        assert_eq!(a.len(), 32, "exact byte count");
292        // Two draws of 32 bytes are astronomically unlikely to match.
293        let b = fs
294            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
295            .await
296            .unwrap();
297        assert_ne!(a, b, "entropy: two draws must differ");
298        // `random` aliases the same source.
299        let c = fs
300            .read_range(Path::new("random"), Some(ReadRange::bytes(0, 8)))
301            .await
302            .unwrap();
303        assert_eq!(c.len(), 8);
304    }
305
306    #[tokio::test]
307    async fn urandom_whole_read_is_loud_error() {
308        let fs = DevFs::new();
309        let err = fs.read(Path::new("urandom")).await.unwrap_err();
310        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
311        assert!(err.to_string().contains("head -c"), "names the fix: {err}");
312    }
313
314    #[tokio::test]
315    async fn unknown_device_is_not_found() {
316        let fs = DevFs::new();
317        assert_eq!(
318            fs.read(Path::new("sda")).await.unwrap_err().kind(),
319            io::ErrorKind::NotFound
320        );
321        assert_eq!(
322            fs.write(Path::new("sda"), b"x").await.unwrap_err().kind(),
323            io::ErrorKind::NotFound
324        );
325    }
326
327    #[tokio::test]
328    async fn list_shows_devices() {
329        let fs = DevFs::new();
330        let names: Vec<_> = fs
331            .list(Path::new(""))
332            .await
333            .unwrap()
334            .into_iter()
335            .map(|e| e.name)
336            .collect();
337        assert_eq!(
338            names,
339            vec![
340                "null".to_string(),
341                "random".to_string(),
342                "urandom".to_string(),
343                "zero".to_string()
344            ]
345        );
346    }
347
348    #[tokio::test]
349    async fn stat_devices_and_root() {
350        let fs = DevFs::new();
351        assert_eq!(fs.stat(Path::new("")).await.unwrap().kind, DirEntryKind::Directory);
352        for dev in ["null", "zero", "urandom", "random"] {
353            let e = fs.stat(Path::new(dev)).await.unwrap();
354            assert_eq!(e.kind, DirEntryKind::File, "{dev}");
355            assert_eq!(e.name, dev, "stat names the device");
356        }
357        assert_eq!(
358            fs.stat(Path::new("nope")).await.unwrap_err().kind(),
359            io::ErrorKind::NotFound
360        );
361    }
362}