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    /// Mode reported for a device node, matching `crw-rw-rw-` on Linux:
102    /// everyone reads, everyone writes, nobody executes. `write` accepts and
103    /// discards for every device, so the write bit is the truth.
104    pub const DEVICE_MODE: u32 = 0o666;
105
106    /// Mode reported for the `/dev` directory itself: searchable and
107    /// readable, **not** writable.
108    ///
109    /// Linux ships `/dev` as 0755, but that write bit is for root, and kaish
110    /// has no root: `mkdir` and `remove` below refuse every caller
111    /// unconditionally. 0755 would make `test -w /dev` answer YES about a
112    /// directory that accepts nothing, which is the exact failure this mode
113    /// exists to prevent. 0555 is the mode that tells the truth here.
114    pub const DIRECTORY_MODE: u32 = 0o555;
115
116    fn entry(name: &str) -> DirEntry {
117        DirEntry {
118            name: name.to_string(),
119            kind: DirEntryKind::File,
120            size: 0,
121            modified: None,
122            permissions: Some(Self::DEVICE_MODE),
123            symlink_target: None,
124        }
125    }
126}
127
128#[async_trait]
129impl Filesystem for DevFs {
130    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
131        match Self::device(path) {
132            Some(Device::Null) => Ok(Vec::new()),
133            Some(dev) => Err(Self::unbounded(dev.name())), // endless: no whole read
134            None => Err(Self::not_found(path)),
135        }
136    }
137
138    async fn read_range(&self, path: &Path, range: Option<ReadRange>) -> io::Result<Vec<u8>> {
139        let Some(dev) = Self::device(path) else {
140            return Err(Self::not_found(path));
141        };
142        // The sink ignores any range — it is always empty.
143        if dev == Device::Null {
144            return Ok(Vec::new());
145        }
146        // Endless stream: only a byte count is answerable. A None range or a
147        // line-only range is the unbounded "give me everything" ask.
148        let limit = match range.and_then(|r| r.limit) {
149            Some(n) => n,
150            None => return Err(Self::unbounded(dev.name())),
151        };
152        if limit > MAX_DEVICE_READ_BYTES {
153            return Err(io::Error::new(
154                io::ErrorKind::InvalidInput,
155                format!(
156                    "requested {limit} bytes from /dev/{} exceeds the device read cap \
157                     of {MAX_DEVICE_READ_BYTES} bytes",
158                    dev.name()
159                ),
160            ));
161        }
162        let mut buf = vec![0u8; limit as usize];
163        if dev == Device::Random {
164            getrandom::fill(&mut buf).map_err(|e| {
165                io::Error::other(format!("/dev/{}: entropy source failed: {e}", dev.name()))
166            })?;
167        }
168        Ok(buf) // Device::Zero leaves the buffer zeroed
169    }
170
171    async fn write(&self, path: &Path, _data: &[u8]) -> io::Result<()> {
172        // Every device accepts and discards writes — `cmd > /dev/null` is the
173        // whole point. Writing to an unknown device is still an error.
174        match Self::device(path) {
175            Some(_) => Ok(()),
176            None => Err(Self::not_found(path)),
177        }
178    }
179
180    async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
181        // The trait default reads before writing, but `read` on every device
182        // except /dev/null errors "unbounded" (see above) — `tee -a >
183        // /dev/zero` would fail loudly for the wrong reason. Append has the
184        // same discard-or-not-found contract as write, so delegate directly
185        // instead of reading first.
186        self.write(path, data).await
187    }
188
189    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
190        if Self::is_root(path) {
191            return Ok(Self::NAMES.iter().map(|n| Self::entry(n)).collect());
192        }
193        if Self::device(path).is_some() {
194            return Err(io::Error::new(
195                io::ErrorKind::NotADirectory,
196                format!("not a directory: /dev/{}", path.display()),
197            ));
198        }
199        Err(Self::not_found(path))
200    }
201
202    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
203        if Self::is_root(path) {
204            return Ok(DirEntry {
205                name: "dev".to_string(),
206                kind: DirEntryKind::Directory,
207                size: 0,
208                modified: None,
209                permissions: Some(Self::DIRECTORY_MODE),
210                symlink_target: None,
211            });
212        }
213        match Self::device(path) {
214            Some(_) => Ok(Self::entry(
215                path.to_str().unwrap_or_default().trim_start_matches('/'),
216            )),
217            None => Err(Self::not_found(path)),
218        }
219    }
220
221    async fn mkdir(&self, path: &Path) -> io::Result<()> {
222        Err(io::Error::new(
223            io::ErrorKind::PermissionDenied,
224            format!("/dev is read-only: cannot create {}", path.display()),
225        ))
226    }
227
228    async fn remove(&self, path: &Path) -> io::Result<()> {
229        Err(io::Error::new(
230            io::ErrorKind::PermissionDenied,
231            format!("/dev is read-only: cannot remove {}", path.display()),
232        ))
233    }
234
235    fn read_only(&self) -> bool {
236        // Writes to the devices "succeed" (they discard), so the mount is not
237        // read-only in the sense the router cares about — refusing a write
238        // would break `> /dev/null`. The consequence is that the mount cannot
239        // answer `test -w` here and the modes have to: DEVICE_MODE is
240        // writable, DIRECTORY_MODE is not.
241        false
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[tokio::test]
250    async fn null_reads_empty() {
251        let fs = DevFs::new();
252        assert_eq!(fs.read(Path::new("null")).await.unwrap(), b"");
253        // A counted read of null is still empty.
254        assert_eq!(
255            fs.read_range(Path::new("null"), Some(ReadRange::bytes(0, 16)))
256                .await
257                .unwrap(),
258            b""
259        );
260    }
261
262    #[tokio::test]
263    async fn null_discards_writes() {
264        let fs = DevFs::new();
265        fs.write(Path::new("null"), b"anything at all").await.unwrap();
266    }
267
268    #[tokio::test]
269    async fn zero_counted_read_yields_zeros() {
270        let fs = DevFs::new();
271        let out = fs
272            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, 8)))
273            .await
274            .unwrap();
275        assert_eq!(out, vec![0u8; 8]);
276    }
277
278    #[tokio::test]
279    async fn zero_whole_read_is_loud_error() {
280        let fs = DevFs::new();
281        let err = fs.read(Path::new("zero")).await.unwrap_err();
282        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
283        assert!(err.to_string().contains("head -c"), "should name the fix: {err}");
284
285        // A None range through read_range is the same unbounded ask.
286        let err = fs.read_range(Path::new("zero"), None).await.unwrap_err();
287        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
288    }
289
290    #[tokio::test]
291    async fn zero_read_cap_is_enforced() {
292        let fs = DevFs::new();
293        let err = fs
294            .read_range(Path::new("zero"), Some(ReadRange::bytes(0, MAX_DEVICE_READ_BYTES + 1)))
295            .await
296            .unwrap_err();
297        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
298        assert!(err.to_string().contains("cap"), "should mention the cap: {err}");
299    }
300
301    #[tokio::test]
302    async fn urandom_counted_read_is_random_and_sized() {
303        let fs = DevFs::new();
304        let a = fs
305            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
306            .await
307            .unwrap();
308        assert_eq!(a.len(), 32, "exact byte count");
309        // Two draws of 32 bytes are astronomically unlikely to match.
310        let b = fs
311            .read_range(Path::new("urandom"), Some(ReadRange::bytes(0, 32)))
312            .await
313            .unwrap();
314        assert_ne!(a, b, "entropy: two draws must differ");
315        // `random` aliases the same source.
316        let c = fs
317            .read_range(Path::new("random"), Some(ReadRange::bytes(0, 8)))
318            .await
319            .unwrap();
320        assert_eq!(c.len(), 8);
321    }
322
323    #[tokio::test]
324    async fn urandom_whole_read_is_loud_error() {
325        let fs = DevFs::new();
326        let err = fs.read(Path::new("urandom")).await.unwrap_err();
327        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
328        assert!(err.to_string().contains("head -c"), "names the fix: {err}");
329    }
330
331    #[tokio::test]
332    async fn unknown_device_is_not_found() {
333        let fs = DevFs::new();
334        assert_eq!(
335            fs.read(Path::new("sda")).await.unwrap_err().kind(),
336            io::ErrorKind::NotFound
337        );
338        assert_eq!(
339            fs.write(Path::new("sda"), b"x").await.unwrap_err().kind(),
340            io::ErrorKind::NotFound
341        );
342    }
343
344    #[tokio::test]
345    async fn list_shows_devices() {
346        let fs = DevFs::new();
347        let names: Vec<_> = fs
348            .list(Path::new(""))
349            .await
350            .unwrap()
351            .into_iter()
352            .map(|e| e.name)
353            .collect();
354        assert_eq!(
355            names,
356            vec![
357                "null".to_string(),
358                "random".to_string(),
359                "urandom".to_string(),
360                "zero".to_string()
361            ]
362        );
363    }
364
365    #[tokio::test]
366    async fn stat_devices_and_root() {
367        let fs = DevFs::new();
368        assert_eq!(fs.stat(Path::new("")).await.unwrap().kind, DirEntryKind::Directory);
369        for dev in ["null", "zero", "urandom", "random"] {
370            let e = fs.stat(Path::new(dev)).await.unwrap();
371            assert_eq!(e.kind, DirEntryKind::File, "{dev}");
372            assert_eq!(e.name, dev, "stat names the device");
373        }
374        assert_eq!(
375            fs.stat(Path::new("nope")).await.unwrap_err().kind(),
376            io::ErrorKind::NotFound
377        );
378    }
379}