From 4fcc29b7defa04a22d8df7effc77e94e424986d4 Mon Sep 17 00:00:00 2001 From: Weidong Cui Date: Fri, 3 Apr 2026 20:09:07 -0700 Subject: [PATCH] fs/devices: add PTY subsystem, separate stdio nodes, nonblocking stdin, and device status flags Add full pseudo-terminal (PTY) support with PtyManager, PtyPair shared state, master/slave ring buffers, and line discipline processing (ICRNL, ECHO, ONLCR). PTY operations (get/set termios, foreground pgrp) are inherent methods on devices::FileSystem rather than trait methods, since they are too device-specific for the generic FileSystem trait. Other changes: - Separate NodeInfo constants for stdin/stdout/stderr (distinct ino) - Add /dev/tty (controlling terminal) routing reads to stdin, writes to stdout - Nonblocking stdin support via platform read_from_stdin_nonblocking - StdinPollable, PtyMasterPollable, PtySlavePollable for epoll/poll/select - DeviceStatusFlags for per-entry O_NONBLOCK tracking - Manual FdEnabledSubsystemEntry with on_dup/on_close for PTY refcounting - Implement *_at stubs (NotADirectory), fd_path, rename, set_open_status_flags - /dev and /dev/pts directory stat support - Proper error returns instead of unimplemented!() in open() and read() - Zero-length read/write guards for PTY paths --- litebox/src/fs/devices.rs | 958 ++++++++++++++++++++++++++++++++++---- 1 file changed, 872 insertions(+), 86 deletions(-) diff --git a/litebox/src/fs/devices.rs b/litebox/src/fs/devices.rs index 9d58272b7..8e8512848 100644 --- a/litebox/src/fs/devices.rs +++ b/litebox/src/fs/devices.rs @@ -4,20 +4,29 @@ //! Device provider for LiteBox including: //! 1. Standard input/output devices. //! 2. /dev/null device. +//! 3. Pseudo-terminal (PTY) devices (/dev/ptmx, /dev/pts/*). +use alloc::collections::VecDeque; use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use alloc::sync::Weak; use crate::{ LiteBox, + event::{Events, IOPollable, observer::Observer, polling::Pollee}, + fd::MetadataError, fs::{ FileStatus, FileType, Mode, NodeInfo, OFlags, SeekWhence, UserInfo, errors::{ ChmodError, ChownError, CloseError, FileStatusError, MkdirError, OpenError, PathError, - ReadDirError, ReadError, RmdirError, SeekError, TruncateError, UnlinkError, WriteError, + ReadDirError, ReadError, RenameError, RmdirError, SeekError, TruncateError, + UnlinkError, WriteError, }, }, path::Arg, - platform::{StdioOutStream, StdioReadError, StdioWriteError}, + platform::{StdioOutStream, StdioReadError, StdioWriteError, TimeProvider}, }; /// Block size for stdio devices @@ -27,17 +36,31 @@ const NULL_BLOCK_SIZE: usize = 0x1000; /// Block size for /dev/urandom const URANDOM_BLOCK_SIZE: usize = 0x1000; -/// Constant node information for all 3 stdio devices: -/// ```console -/// $ stat -L --format 'name=%-11n dev=%d ino=%i rdev=%r' /dev/stdin /dev/stdout /dev/stderr -/// name=/dev/stdin dev=64 ino=9 rdev=34822 -/// name=/dev/stdout dev=64 ino=9 rdev=34822 -/// name=/dev/stderr dev=64 ino=9 rdev=34822 -/// ``` -const STDIO_NODE_INFO: NodeInfo = NodeInfo { +/// Constant node information for stdin. +const STDIN_NODE_INFO: NodeInfo = NodeInfo { dev: 64, ino: 9, - rdev: core::num::NonZeroUsize::new(34822), + // major=5, minor=0 — matches /dev/tty (character device, terminal). + rdev: core::num::NonZeroUsize::new(0x500), +}; +/// Constant node information for stdout. +const STDOUT_NODE_INFO: NodeInfo = NodeInfo { + dev: 64, + ino: 10, + rdev: core::num::NonZeroUsize::new(0x500), +}; +/// Constant node information for stderr. +const STDERR_NODE_INFO: NodeInfo = NodeInfo { + dev: 64, + ino: 11, + rdev: core::num::NonZeroUsize::new(0x500), +}; +/// Node info for /dev/tty (controlling terminal) +const TTY_NODE_INFO: NodeInfo = NodeInfo { + dev: 5, + ino: 12, + // major=5, minor=0 — matches the controlling terminal + rdev: core::num::NonZeroUsize::new(0x500), }; /// Node info for /dev/null const NULL_NODE_INFO: NodeInfo = NodeInfo { @@ -53,28 +76,232 @@ const URANDOM_NODE_INFO: NodeInfo = NodeInfo { // major=1, minor=9 rdev: core::num::NonZeroUsize::new(0x109), }; +/// Node info for /dev/ptmx +const PTMX_NODE_INFO: NodeInfo = NodeInfo { + dev: 5, + ino: 2, + // major=5, minor=2 + rdev: core::num::NonZeroUsize::new(0x502), +}; + +/// Stored terminal attributes for a PTY pair. +/// +/// This is a re-export of [`crate::platform::TerminalAttributes`] — the same +/// type used by the platform abstraction for host stdio terminals. Using a +/// single type avoids duplication and simplifies conversions. +pub type PtyTermios = crate::platform::TerminalAttributes; + +/// Per-entry status flags for device-backed file descriptors. +#[derive(Clone, Copy)] +pub struct DeviceStatusFlags(pub OFlags); + +impl DeviceStatusFlags { + /// Returns the stored status flags. + #[must_use] + pub fn get_status(&self) -> OFlags { + self.0 & OFlags::STATUS_FLAGS_MASK + } + + /// Sets or clears a status flag. + pub fn set_status(&mut self, flag: OFlags, on: bool) { + if on { + self.0 |= flag; + } else { + self.0 &= !flag; + } + } +} + +/// Shared state for a single PTY pair (master ↔ slave). +pub struct PtyPair { + /// Data written to master, read by slave (input to the child process). + pub master_to_slave: crate::sync::Mutex>, + /// Data written to slave, read by master (output from the child process). + pub slave_to_master: crate::sync::Mutex>, + /// Whether the slave side has been unlocked via TIOCSPTLK. + pub unlocked: core::sync::atomic::AtomicBool, + /// Reference count of open slave FDs (used for EOF detection). + /// Incremented on open and FD duplication, decremented on close. + pub slave_open_count: core::sync::atomic::AtomicU32, + /// Reference count of open master FDs (used for EIO on slave reads). + /// Incremented on open and FD duplication, decremented on close. + pub master_open_count: core::sync::atomic::AtomicU32, + /// Pollee for notifying epoll when data is available for master reads. + pub master_pollee: Pollee, + /// Pollee for notifying poll/select when data is available for slave reads. + pub slave_pollee: Pollee, + /// Stored terminal attributes — modified by TCSETS, read by TCGETS. + pub termios: crate::sync::Mutex, + /// Foreground process group for this PTY — modified by TIOCSPGRP, read by + /// TIOCGPGRP. Stored as a raw pid_t; 0 means "not yet set". + pub foreground_pgrp: core::sync::atomic::AtomicI32, +} + +/// Wrapper for polling the master side of a PTY pair. +/// +/// Implements `IOPollable` so that epoll can monitor the PTY master fd +/// for readability (data written by slave) and writability. +pub struct PtyMasterPollable( + pub Arc>, +); + +impl IOPollable + for PtyMasterPollable +{ + fn register_observer(&self, observer: Weak>, mask: Events) { + self.0.master_pollee.register_observer(observer, mask); + } + + fn check_io_events(&self) -> Events { + let mut events = Events::OUT; // master is always writable + if !self.0.slave_to_master.lock().is_empty() { + events |= Events::IN; + } + if self + .0 + .slave_open_count + .load(core::sync::atomic::Ordering::Acquire) + == 0 + { + events |= Events::HUP; + } + events + } + + fn should_block_read(&self) -> bool { + false + } +} + +/// Wrapper for polling the slave side of a PTY pair. +/// +/// Implements `IOPollable` so that poll/select can monitor the PTY slave fd +/// for readability (data written by master) and writability. +pub struct PtySlavePollable( + pub Arc>, +); + +impl IOPollable + for PtySlavePollable +{ + fn register_observer(&self, observer: Weak>, mask: Events) { + self.0.slave_pollee.register_observer(observer, mask); + } + + fn check_io_events(&self) -> Events { + let mut events = Events::OUT; // slave is always writable + if !self.0.master_to_slave.lock().is_empty() { + events |= Events::IN; + } + if self + .0 + .master_open_count + .load(core::sync::atomic::Ordering::Acquire) + == 0 + { + events |= Events::HUP; + } + events + } +} + +/// Wrapper for polling host stdin. +/// +/// Implements `IOPollable` by querying the host kernel to check if stdin has +/// data available. This enables epoll/poll/select on the guest's stdin fd. +pub struct StdinPollable(pub Arc bool + Send + Sync>); + +impl IOPollable for StdinPollable { + fn register_observer(&self, _observer: Weak>, _mask: Events) { + // Stdin observers are not cached — check_io_events polls the host each + // time. The epoll wait loop calls scan_once repeatedly with short + // timeouts, so this effectively acts as level-triggered polling. + } + + fn check_io_events(&self) -> Events { + let mut events = Events::OUT; + if (self.0)() { + events |= Events::IN; + } + events + } + + fn needs_host_poll(&self) -> bool { + true + } +} + +/// Manages all allocated PTY pairs. +pub struct PtyManager { + pairs: crate::sync::Mutex>>>, +} + +impl PtyManager { + fn new() -> Self { + Self { + pairs: crate::sync::Mutex::new(Vec::new()), + } + } + + /// Allocate a new PTY pair, returning its index. + fn alloc(&self) -> u32 { + let mut pairs = self.pairs.lock(); + let idx = u32::try_from(pairs.len()).expect("PTY index overflow"); + pairs.push(Arc::new(PtyPair { + master_to_slave: crate::sync::Mutex::new(VecDeque::new()), + slave_to_master: crate::sync::Mutex::new(VecDeque::new()), + unlocked: true.into(), + slave_open_count: core::sync::atomic::AtomicU32::new(0), + master_open_count: core::sync::atomic::AtomicU32::new(1), + master_pollee: Pollee::new(), + slave_pollee: Pollee::new(), + termios: crate::sync::Mutex::new(PtyTermios::new_default()), + foreground_pgrp: core::sync::atomic::AtomicI32::new(0), + })); + idx + } + + /// Get the PTY pair at `idx`. + pub fn get(&self, idx: u32) -> Option>> { + self.pairs.lock().get(idx as usize).cloned() + } +} + #[derive(Debug, Clone, Copy)] enum Device { Stdin, Stdout, Stderr, + /// Controlling terminal (/dev/tty) — reads from stdin, writes to stdout. + Tty, Null, URandom, + /// Master side of PTY pair (index into PtyManager). + PtyMaster(u32), + /// Slave side of PTY pair (index into PtyManager). + PtySlave(u32), } /// A backing implementation for [`FileSystem`](super::FileSystem). /// -/// This provider provides only `/dev/stdin`, `/dev/stdout`, and `/dev/stderr`. +/// This provider provides `/dev/stdin`, `/dev/stdout`, `/dev/stderr`, +/// `/dev/null`, `/dev/urandom`, and pseudo-terminal devices (`/dev/ptmx`, +/// `/dev/pts/*`). pub struct FileSystem< - Platform: crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider + 'static, + Platform: crate::sync::RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + TimeProvider + + 'static, > { litebox: LiteBox, // cwd invariant: always ends with a `/` current_working_dir: String, + pty_manager: PtyManager, } -impl - FileSystem +impl< + Platform: crate::platform::StdioProvider + crate::sync::RawSyncPrimitivesProvider + TimeProvider, +> FileSystem { /// Construct a new `FileSystem` instance /// @@ -86,17 +313,92 @@ impl, + ) -> Option<(Arc>, u32, bool)> { + let table = self.litebox.descriptor_table(); + let entry = table.get_entry(fd)?; + match entry.entry { + Device::PtyMaster(idx) => { + let pair = self.pty_manager.get(idx)?; + Some((pair, idx, true)) + } + Device::PtySlave(idx) => { + let pair = self.pty_manager.get(idx)?; + Some((pair, idx, false)) + } + _ => None, + } + } + + /// Returns the PTY pair index if the given FD is a PTY master, or `None` otherwise. + pub fn get_pty_master_index(&self, fd: &FileFd) -> Option { + let table = self.litebox.descriptor_table(); + match table.get_entry(fd)?.entry { + Device::PtyMaster(idx) => Some(idx), + _ => None, + } + } + + /// Returns a reference to the PTY manager for performing PTY operations. + pub fn pty_manager(&self) -> &PtyManager { + &self.pty_manager + } + + /// Get the stored terminal attributes for a PTY device FD. + pub fn get_pty_termios(&self, fd: &FileFd) -> Option { + let (pair, _, _) = self.get_pty_info(fd)?; + Some(pair.termios.lock().clone()) + } + + /// Set the terminal attributes for a PTY device FD. + pub fn set_pty_termios(&self, fd: &FileFd, termios: PtyTermios) -> bool { + if let Some((pair, _, _)) = self.get_pty_info(fd) { + *pair.termios.lock() = termios; + true + } else { + false + } + } + + /// Get the foreground process group for a PTY device FD. + pub fn get_pty_foreground_pgrp(&self, fd: &FileFd) -> Option { + let (pair, _, _) = self.get_pty_info(fd)?; + Some( + pair.foreground_pgrp + .load(core::sync::atomic::Ordering::Relaxed), + ) + } + + /// Set the foreground process group for a PTY device FD. + pub fn set_pty_foreground_pgrp(&self, fd: &FileFd, pgrp: i32) -> bool { + if let Some((pair, _, _)) = self.get_pty_info(fd) { + pair.foreground_pgrp + .store(pgrp, core::sync::atomic::Ordering::Relaxed); + true + } else { + false } } } -impl - super::private::Sealed for FileSystem +impl< + Platform: crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider + TimeProvider, +> super::private::Sealed for FileSystem { } -impl - FileSystem +impl< + Platform: crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider + TimeProvider, +> FileSystem { // Gives the absolute path for `path`, resolving any `.` or `..`s, and making sure to account // for any relative paths from current working directory. @@ -116,12 +418,36 @@ impl FileStatus { match device { - Device::Stdin | Device::Stdout | Device::Stderr => FileStatus { + Device::Stdin => FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR | Mode::WUSR | Mode::WGRP, + size: 0, + owner: UserInfo::ROOT, + node_info: STDIN_NODE_INFO, + blksize: STDIO_BLOCK_SIZE, + }, + Device::Stdout => FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR | Mode::WUSR | Mode::WGRP, + size: 0, + owner: UserInfo::ROOT, + node_info: STDOUT_NODE_INFO, + blksize: STDIO_BLOCK_SIZE, + }, + Device::Stderr => FileStatus { file_type: FileType::CharacterDevice, mode: Mode::RUSR | Mode::WUSR | Mode::WGRP, size: 0, owner: UserInfo::ROOT, - node_info: STDIO_NODE_INFO, + node_info: STDERR_NODE_INFO, + blksize: STDIO_BLOCK_SIZE, + }, + Device::Tty => FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP, + size: 0, + owner: UserInfo::ROOT, + node_info: TTY_NODE_INFO, blksize: STDIO_BLOCK_SIZE, }, Device::Null => FileStatus { @@ -140,6 +466,19 @@ impl FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP, + size: 0, + owner: UserInfo::ROOT, + node_info: NodeInfo { + dev: 5, + // major=136, minor=n (Linux pts convention) + ino: (n + 3) as usize, + rdev: core::num::NonZeroUsize::new(0x8800 + n as usize), + }, + blksize: STDIO_BLOCK_SIZE, + }, } } } @@ -147,7 +486,9 @@ impl super::FileSystem for FileSystem { fn open( @@ -156,53 +497,117 @@ impl< flags: OFlags, mode: Mode, ) -> Result, OpenError> { + let requested_status = flags & OFlags::STATUS_FLAGS_MASK; let open_directory = flags.contains(OFlags::DIRECTORY); let flags = flags - OFlags::DIRECTORY; - let nonblocking = flags.contains(OFlags::NONBLOCK); + let _nonblocking = flags.contains(OFlags::NONBLOCK); let flags = flags - OFlags::NONBLOCK; // ignore NOCTTY, NOFOLLOW, and APPEND let flags = flags - OFlags::NOCTTY - OFlags::NOFOLLOW - OFlags::APPEND; let truncate = flags.contains(OFlags::TRUNC); let flags = flags - OFlags::TRUNC; + let excl = flags.contains(OFlags::EXCL); + // Existing device nodes ignore create-only metadata and large-file mode bits. + let flags = flags - OFlags::CREAT - OFlags::EXCL - OFlags::LARGEFILE; + let _ = mode; let path = self.absolute_path(path)?; - let device = match path.as_str() { + let (device, pty_pair) = match path.as_str() { "/dev/stdin" => { - if flags == OFlags::RDONLY && mode.is_empty() { - Device::Stdin + if excl { + return Err(OpenError::AlreadyExists); + } + if flags == OFlags::RDONLY || flags == OFlags::RDWR { + (Device::Stdin, None) } else { - unimplemented!() + return Err(OpenError::AccessNotAllowed); } } "/dev/stdout" => { - if flags == OFlags::WRONLY && mode.is_empty() { - Device::Stdout + if excl { + return Err(OpenError::AlreadyExists); + } + if flags == OFlags::WRONLY || flags == OFlags::RDWR { + (Device::Stdout, None) } else { - unimplemented!() + return Err(OpenError::AccessNotAllowed); } } "/dev/stderr" => { - if flags == OFlags::WRONLY && mode.is_empty() { - Device::Stderr + if excl { + return Err(OpenError::AlreadyExists); + } + if flags == OFlags::WRONLY || flags == OFlags::RDWR { + (Device::Stderr, None) } else { - unimplemented!() + return Err(OpenError::AccessNotAllowed); + } + } + "/dev/null" => { + if excl { + return Err(OpenError::AlreadyExists); + } + (Device::Null, None) + } + "/dev/urandom" => { + if excl { + return Err(OpenError::AlreadyExists); + } + (Device::URandom, None) + } + "/dev/tty" => { + if excl { + return Err(OpenError::AlreadyExists); + } + (Device::Tty, None) + } + "/dev/ptmx" => { + if excl { + return Err(OpenError::AlreadyExists); + } + let idx = self.pty_manager.alloc(); + let pair = self.pty_manager.get(idx).unwrap(); + (Device::PtyMaster(idx), Some(pair)) + } + p if p.starts_with("/dev/pts/") => { + if excl { + return Err(OpenError::AlreadyExists); + } + let num_str = &p["/dev/pts/".len()..]; + let idx: u32 = num_str + .parse() + .map_err(|_| OpenError::PathError(PathError::NoSuchFileOrDirectory))?; + // A sandbox-created PTY always takes priority over the host + // terminal alias. Only fall through to Device::Tty when no + // sandbox PTY with this index exists AND the path matches the + // host's actual tty. + if let Some(pair) = self.pty_manager.get(idx) { + if !pair.unlocked.load(core::sync::atomic::Ordering::Acquire) { + return Err(OpenError::AccessNotAllowed); + } + pair.slave_open_count + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); + (Device::PtySlave(idx), Some(pair)) + } else if self + .litebox + .x + .platform + .host_stdin_tty_device_info() + .is_some_and(|info| p == info.path) + { + (Device::Tty, None) + } else { + return Err(OpenError::PathError(PathError::NoSuchFileOrDirectory)); } } - "/dev/null" => Device::Null, - "/dev/urandom" => Device::URandom, _ => return Err(OpenError::PathError(PathError::NoSuchFileOrDirectory)), }; if open_directory { return Err(OpenError::PathError(PathError::ComponentNotADirectory)); } - if nonblocking - && matches!( - device, - Device::Stdin | Device::Stderr | Device::Stdout | Device::URandom - ) - { - unimplemented!("Non-blocking I/O is not supported for {:?}", device); - } - let fd = self.litebox.descriptor_table_mut().insert(device); + let fd = self.litebox.descriptor_table_mut().insert(DescriptorEntry { + entry: device, + pty_pair, + }); if truncate { // Note: matching Linux behavior, this does not actually perform any truncation, and // instead, it is silently ignored if you attempt to truncate upon opening stdio. @@ -211,10 +616,13 @@ impl< Err(TruncateError::IsTerminalDevice) )); } + self.set_open_status_flags(&fd, requested_status) + .map_err(|_| OpenError::Io)?; Ok(fd) } fn close(&self, fd: &FileFd) -> Result<(), CloseError> { + // on_close() in DescriptorEntry handles PTY refcount decrement + HUP. self.litebox.descriptor_table_mut().remove(fd); Ok(()) } @@ -223,46 +631,100 @@ impl< &self, fd: &FileFd, buf: &mut [u8], - offset: Option, + _offset: Option, ) -> Result { - match &self - .litebox - .descriptor_table() - .get_entry(fd) - .ok_or(ReadError::ClosedFd)? - .entry - { - Device::Stdin => {} - Device::Stdout | Device::Stderr => { - return Err(ReadError::NotForReading); - } - Device::Null => { - // /dev/null read returns EOF - return Ok(0); - } - Device::URandom => { - self.litebox.x.platform.fill_bytes_crng(buf); - return Ok(buf.len()); + let nonblocking = { + let table = self.litebox.descriptor_table(); + let nonblocking = table + .with_metadata(fd, |DeviceStatusFlags(flags)| { + flags.contains(OFlags::NONBLOCK) + }) + .unwrap_or(false); + match &table.get_entry(fd).ok_or(ReadError::ClosedFd)?.entry { + Device::Stdin | Device::Tty => nonblocking, + Device::Stdout | Device::Stderr => { + return Err(ReadError::NotForReading); + } + Device::Null => { + // /dev/null read returns EOF + return Ok(0); + } + Device::URandom => { + self.litebox.x.platform.fill_bytes_crng(buf); + return Ok(buf.len()); + } + &Device::PtyMaster(idx) => { + if buf.is_empty() { + return Ok(0); + } + // Master reads what the slave has written + let pair = self.pty_manager.get(idx).ok_or(ReadError::ClosedFd)?; + let mut ring = pair.slave_to_master.lock(); + if ring.is_empty() { + if pair + .slave_open_count + .load(core::sync::atomic::Ordering::Acquire) + == 0 + { + return Ok(0); // EOF — slave closed + } + return Err(ReadError::WouldBlock); + } + let n = core::cmp::min(buf.len(), ring.len()); + for (i, byte) in ring.drain(..n).enumerate() { + buf[i] = byte; + } + return Ok(n); + } + &Device::PtySlave(idx) => { + if buf.is_empty() { + return Ok(0); + } + // Slave reads what the master has written. + // Returns WouldBlock (EAGAIN) when no data is available; + // the shim layer handles blocking retry with interruptibility. + let pair = self.pty_manager.get(idx).ok_or(ReadError::ClosedFd)?; + let mut ring = pair.master_to_slave.lock(); + if ring.is_empty() { + if pair + .master_open_count + .load(core::sync::atomic::Ordering::Acquire) + == 0 + { + return Ok(0); // EOF — master closed + } + return Err(ReadError::WouldBlock); + } + let n = core::cmp::min(buf.len(), ring.len()); + for (i, byte) in ring.drain(..n).enumerate() { + buf[i] = byte; + } + return Ok(n); + } } + }; + // Stdin is a stream device — offsets are meaningless. Ignore any + // explicit offset (the layered FS may supply one for concurrency safety). + if buf.is_empty() { + return Ok(0); } - if offset.is_some() { - unimplemented!() + let read_result = if nonblocking { + self.litebox.x.platform.read_from_stdin_nonblocking(buf) + } else { + self.litebox.x.platform.read_from_stdin(buf) + }; + match read_result { + Ok(n) => Ok(n), + Err(StdioReadError::Closed) => Ok(0), // EOF — terminal disconnected + Err(StdioReadError::WouldBlock) => Err(ReadError::WouldBlock), } - self.litebox - .x - .platform - .read_from_stdin(buf) - .map_err(|e| match e { - StdioReadError::Closed => unimplemented!(), - StdioReadError::WouldBlock => unimplemented!(), - }) } fn write( &self, fd: &FileFd, buf: &[u8], - offset: Option, + _offset: Option, ) -> Result { let stream = match &self .litebox @@ -272,7 +734,7 @@ impl< .entry { Device::Stdin => return Err(WriteError::NotForWriting), - Device::Stdout => StdioOutStream::Stdout, + Device::Stdout | Device::Tty => StdioOutStream::Stdout, Device::Stderr => StdioOutStream::Stderr, Device::Null | Device::URandom => { // /dev/null discards data: report as if written fully @@ -285,10 +747,74 @@ impl< // /dev/urandom here. return Ok(buf.len()); } + &Device::PtyMaster(idx) => { + // Master writes feed the slave's input buffer. + // Line discipline: ICRNL translates \r → \n (if enabled). + // ECHO reflects input back to master's read buffer. + // + // Lock ordering: master_to_slave before slave_to_master when + // echo is enabled. No other code path takes both locks. + let pair = self.pty_manager.get(idx).ok_or(WriteError::ClosedFd)?; + let termios = pair.termios.lock(); + let icrnl = termios.icrnl_enabled(); + let echo = termios.echo_enabled(); + let onlcr = termios.onlcr_enabled(); + drop(termios); + { + let mut slave_ring = pair.master_to_slave.lock(); + if echo { + let mut master_ring = pair.slave_to_master.lock(); + for &b in buf { + let translated = if icrnl && b == b'\r' { b'\n' } else { b }; + slave_ring.push_back(translated); + // Echo: reflect to master read, applying ONLCR. + if onlcr && translated == b'\n' { + master_ring.push_back(b'\r'); + } + master_ring.push_back(translated); + } + } else { + for &b in buf { + slave_ring.push_back(if icrnl && b == b'\r' { b'\n' } else { b }); + } + } + } + if !buf.is_empty() { + pair.slave_pollee.notify_observers(Events::IN); + if echo { + pair.master_pollee.notify_observers(Events::IN); + } + } + return Ok(buf.len()); + } + &Device::PtySlave(idx) => { + // Slave writes feed the master's read buffer. + // Line discipline: ONLCR translates \n → \r\n (if enabled). + let pair = self.pty_manager.get(idx).ok_or(WriteError::ClosedFd)?; + let onlcr = pair.termios.lock().onlcr_enabled(); + { + let mut ring = pair.slave_to_master.lock(); + if onlcr { + for &b in buf { + if b == b'\n' { + ring.push_back(b'\r'); + } + ring.push_back(b); + } + } else { + for &b in buf { + ring.push_back(b); + } + } + } + // Wake epoll watchers on the master side. + if !buf.is_empty() { + pair.master_pollee.notify_observers(Events::IN); + } + return Ok(buf.len()); + } }; - if offset.is_some() { - unimplemented!() - } + // Stdout/stderr are stream devices — offsets are meaningless. self.litebox .x .platform @@ -311,7 +837,12 @@ impl< .ok_or(SeekError::ClosedFd)? .entry { - Device::Stdin | Device::Stdout | Device::Stderr => Err(SeekError::NonSeekable), + Device::Stdin + | Device::Stdout + | Device::Stderr + | Device::Tty + | Device::PtyMaster(_) + | Device::PtySlave(_) => Err(SeekError::NonSeekable), Device::Null | Device::URandom => { // Linux allows lseek on /dev/null and returns position 0 (or sets to length 0). Ok(0) @@ -348,11 +879,15 @@ impl< unimplemented!() } - #[expect(unused_variables, reason = "unimplemented")] - fn mkdir(&self, path: impl Arg, mode: Mode) -> Result<(), MkdirError> { + fn rename(&self, _old: impl Arg, _new: impl Arg) -> Result<(), RenameError> { unimplemented!() } + #[expect(unused_variables, reason = "not supported by device filesystem")] + fn mkdir(&self, path: impl Arg, mode: Mode) -> Result<(), MkdirError> { + Err(MkdirError::Io) + } + #[expect(unused_variables, reason = "unimplemented")] fn rmdir(&self, path: impl Arg) -> Result<(), RmdirError> { unimplemented!() @@ -365,14 +900,78 @@ impl< Err(ReadDirError::NotADirectory) } + #[allow(clippy::cast_possible_truncation)] // 64-bit only target fn file_status(&self, path: impl Arg) -> Result { let path = self.absolute_path(path)?; let device = match path.as_str() { "/dev/stdin" => Device::Stdin, "/dev/stdout" => Device::Stdout, "/dev/stderr" => Device::Stderr, + "/dev/tty" => Device::Tty, "/dev/null" => Device::Null, "/dev/urandom" => Device::URandom, + "/dev/ptmx" => { + return Ok(FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR + | Mode::WUSR + | Mode::RGRP + | Mode::WGRP + | Mode::ROTH + | Mode::WOTH, + size: 0, + owner: UserInfo::ROOT, + node_info: PTMX_NODE_INFO, + blksize: STDIO_BLOCK_SIZE, + }); + } + p if p.starts_with("/dev/pts/") => { + let num_str = &p["/dev/pts/".len()..]; + let idx: u32 = num_str + .parse() + .map_err(|_| FileStatusError::PathError(PathError::NoSuchFileOrDirectory))?; + // Sandbox PTY takes priority over host tty alias, matching + // the open() resolution order. + if self.pty_manager.get(idx).is_some() { + return Ok(Self::device_file_status(Device::PtySlave(idx))); + } + if let Some(info) = self.litebox.x.platform.host_stdin_tty_device_info() + && p == info.path + { + return Ok(FileStatus { + file_type: FileType::CharacterDevice, + mode: Mode::RUSR | Mode::WUSR | Mode::WGRP, + size: 0, + owner: UserInfo::ROOT, + node_info: NodeInfo { + dev: info.dev as usize, + ino: info.ino as usize, + rdev: core::num::NonZeroUsize::new(info.rdev as usize), + }, + blksize: STDIO_BLOCK_SIZE, + }); + } + return Err(FileStatusError::PathError(PathError::NoSuchFileOrDirectory)); + } + "/dev" | "/dev/" | "/dev/pts" | "/dev/pts/" => { + return Ok(FileStatus { + file_type: FileType::Directory, + mode: Mode::RUSR + | Mode::XUSR + | Mode::RGRP + | Mode::XGRP + | Mode::ROTH + | Mode::XOTH, + size: 0, + owner: UserInfo::ROOT, + node_info: NodeInfo { + dev: 5, + ino: 1, + rdev: None, + }, + blksize: 4096, + }); + } _ => return Err(FileStatusError::PathError(PathError::NoSuchFileOrDirectory)), }; Ok(Self::device_file_status(device)) @@ -387,11 +986,198 @@ impl< .entry; Ok(Self::device_file_status(device)) } + + fn get_io_pollable( + &self, + fd: &FileFd, + ) -> Option> { + let table = self.litebox.descriptor_table(); + let entry = table.get_entry(fd)?; + match entry.entry { + Device::Stdin | Device::Tty => { + let litebox = self.litebox.clone(); + Some(alloc::boxed::Box::new(StdinPollable(Arc::new(move || { + litebox.x.platform.poll_stdin_readable() + })))) + } + Device::PtyMaster(idx) => { + let pair = self.pty_manager.get(idx)?; + Some(alloc::boxed::Box::new(PtyMasterPollable(pair))) + } + Device::PtySlave(idx) => { + let pair = self.pty_manager.get(idx)?; + Some(alloc::boxed::Box::new(PtySlavePollable(pair))) + } + _ => None, + } + } + + fn set_open_status_flags( + &self, + fd: &FileFd, + flags: OFlags, + ) -> Result<(), MetadataError> { + let status = flags & OFlags::STATUS_FLAGS_MASK; + let mut table = self.litebox.descriptor_table_mut(); + match table.with_metadata_mut(fd, |DeviceStatusFlags(existing)| { + *existing = status; + }) { + Ok(()) => Ok(()), + Err(MetadataError::NoSuchMetadata) => { + let old = table.set_entry_metadata(fd, DeviceStatusFlags(status)); + debug_assert!(old.is_none()); + Ok(()) + } + Err(MetadataError::ClosedFd) => Err(MetadataError::ClosedFd), + } + } + + fn open_at( + &self, + _dirfd: &FileFd, + _rel_path: impl crate::path::Arg, + _flags: super::OFlags, + _mode: super::Mode, + ) -> Result, super::errors::OpenError> { + // Device fds are not directories — fd-relative open is not meaningful. + Err(super::errors::OpenError::NotADirectory) + } + + fn stat_at( + &self, + _dirfd: &FileFd, + _rel_path: impl crate::path::Arg, + _follow_symlinks: bool, + ) -> Result { + Err(super::errors::FileStatusError::NotADirectory) + } + + fn unlink_at( + &self, + _dirfd: &FileFd, + _rel_path: impl crate::path::Arg, + ) -> Result<(), super::errors::UnlinkError> { + Err(super::errors::UnlinkError::NotADirectory) + } + + fn readlink_at( + &self, + _dirfd: &FileFd, + _rel_path: impl crate::path::Arg, + ) -> Result { + Err(super::errors::ReadLinkError::NotSupported) + } + + fn rename_at( + &self, + _old_dirfd: &FileFd, + _old_rel: impl crate::path::Arg, + _new_dirfd: &FileFd, + _new_rel: impl crate::path::Arg, + ) -> Result<(), super::errors::RenameError> { + Err(super::errors::RenameError::NotSupported) + } + + fn fd_path(&self, _fd: &FileFd) -> Option { + // Devices don't track paths. + None + } + + fn mkdir_at( + &self, + _dirfd: &FileFd, + _rel_path: impl Arg, + _mode: Mode, + ) -> Result<(), MkdirError> { + Err(MkdirError::Io) + } +} + +// Manual implementation of FD subsystem integration for devices. +// We can't use the `enable_fds_for_subsystem!` macro here because we need +// to override `on_dup()` and `on_close()` to properly track PTY slave/master +// reference counts across dup/fork. Without this, closing any single FD +// referencing a PTY slave marks the entire slave as closed, even if other +// FDs (in the same or a child process) still reference it. +#[doc(hidden)] +pub struct DescriptorEntry< + Platform: crate::sync::RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::TimeProvider, +> { + entry: Device, + /// When this FD references a PTY master or slave, holds a reference to + /// the corresponding `PtyPair` so that `on_dup`/`on_close` can adjust + /// the reference count without needing access to the `PtyManager`. + pty_pair: Option>>, +} +impl< + Platform: crate::sync::RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::TimeProvider, +> crate::fd::FdEnabledSubsystem for FileSystem +{ + type Entry = DescriptorEntry; } +impl< + Platform: crate::sync::RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::TimeProvider, +> crate::fd::FdEnabledSubsystemEntry for DescriptorEntry +{ + fn on_dup(&self) { + if let Some(pair) = &self.pty_pair { + match self.entry { + Device::PtyMaster(_) => { + pair.master_open_count + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } + Device::PtySlave(_) => { + pair.slave_open_count + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } + _ => {} + } + } + } -crate::fd::enable_fds_for_subsystem! { - @ Platform: { crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider }; - FileSystem; - Device; - -> FileFd; + fn on_close(&self) { + if let Some(pair) = &self.pty_pair { + match self.entry { + Device::PtyMaster(_) => { + let prev = pair + .master_open_count + .fetch_sub(1, core::sync::atomic::Ordering::AcqRel); + if prev == 1 { + pair.slave_pollee + .notify_observers(crate::event::Events::HUP); + } + } + Device::PtySlave(_) => { + let prev = pair + .slave_open_count + .fetch_sub(1, core::sync::atomic::Ordering::AcqRel); + if prev == 1 { + pair.master_pollee + .notify_observers(crate::event::Events::HUP); + } + } + _ => {} + } + } + } +} +impl< + Platform: crate::sync::RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::TimeProvider, +> From for DescriptorEntry +{ + fn from(entry: Device) -> Self { + Self { + entry, + pty_pair: None, + } + } } +pub type FileFd = crate::fd::TypedFd>;