//! Serial console access is exclusive. Guest commands execute once; only their //! checked response transfer may be retried when kernel messages interleave. use fds_common::{Error, Result}; use sha2::{Digest, Sha256}; use std::{ fs::{File, OpenOptions}, io::{Read, Write}, os::{ fd::AsRawFd, unix::{fs::OpenOptionsExt, net::UnixStream}, }, path::Path, time::{Duration, Instant}, }; pub struct Serial { socket: UnixStream, _lock: File, data: Vec, deadline: Instant, } #[derive(Debug, serde::Serialize)] pub struct Output { pub status: u8, pub output: String, } fn quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } impl Serial { pub fn connect(root: &Path, timeout: u64) -> Result { let lock = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .mode(0o600) .custom_flags(libc::O_NOFOLLOW) .open(root.join("console.lock"))?; if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(Error("The console is already in use; detach it before running guest commands or safe eject".into())); } let socket = UnixStream::connect(root.join("console.sock"))?; socket.set_write_timeout(Some(Duration::from_secs(10)))?; Ok(Self { socket, _lock: lock, data: Vec::new(), deadline: Instant::now() + Duration::from_secs(timeout), }) } fn send(&mut self, text: &str) -> Result<()> { self.socket.write_all(text.as_bytes())?; self.socket.write_all(b"\n")?; Ok(()) } pub fn until(&mut self, marker: &[u8]) -> Result> { loop { if let Some(at) = self.data.windows(marker.len()).position(|w| w == marker) { let before = self.data[..at].to_vec(); self.data.drain(..at + marker.len()); return Ok(before); } let remaining = self.deadline.checked_duration_since(Instant::now()).ok_or_else(|| Error("Guest console timed out; the command may still be running. Inspect console.log before retrying".into()))?; self.socket.set_read_timeout(Some(remaining))?; let mut buffer = [0u8; 8192]; let count = self.socket.read(&mut buffer)?; if count == 0 { return Err(Error("Guest console disconnected".into())); } self.data .extend(buffer[..count].iter().filter(|b| **b != b'\r')); if marker == b"FDS> " { // Preserve the complete boot diagnostic before stopping QEMU. // These words are ordinary data during later guest commands. if let Some(line) = self.data.split_inclusive(|b| *b == b'\n').find(|line| { line.ends_with(b"\n") && (line .windows(b"FDS_STAGE0_ERROR:".len()) .any(|w| w == b"FDS_STAGE0_ERROR:") || line .windows(b"Kernel panic".len()) .any(|w| w == b"Kernel panic")) }) { return Err(Error(String::from_utf8_lossy(line).trim().to_owned())); } } if self.data.len() > 8 * 1024 * 1024 { return Err(Error("Guest console response exceeds 8 MiB".into())); } } } pub fn command(&mut self, args: &[String]) -> Result { if args.is_empty() || args.iter().any(|s| s.contains(['\n', '\r', '\0'])) { return Err(Error( "A guest command and single-line arguments are required".into(), )); } let command = args.iter().map(|s| quote(s)).collect::>().join(" "); if command.len() > 2048 { return Err(Error("Guest command exceeds the console line limit".into())); } self.send("\u{15}")?; let token = fds_burn::image::hex(&fds_burn::image::random_id()?); let path = format!("/tmp/fds-emulator-{token}"); // The directory is private to the ordinary guest user. No root agent or // shell evaluation of caller arguments is involved. self.send(&format!("mkdir -m 700 {path} && {{ ( {command} ) >{path}/output 2>&1; printf '%s' \"$?\" >{path}/status; printf '\\nSAVED_{token}\\n'; }}"))?; self.until(format!("\nSAVED_{token}\n").as_bytes())?; for _ in 0..3 { self.send(&format!("printf '\\nBEGIN_{token}\\n'; od -An -v -tx1 {path}/output; printf '\\nHASH '; sha256sum {path}/output; printf 'STATUS '; cat {path}/status; printf '\\nEND_{token}\\n'"))?; self.until(format!("\nBEGIN_{token}\n").as_bytes())?; let frame = self.until(format!("\nEND_{token}\n").as_bytes())?; if let Some(result) = decode(&frame) { self.send(&format!("rm -rf -- {path}; printf '\\nCLEAN_{token}\\n'"))?; self.until(format!("\nCLEAN_{token}\n").as_bytes())?; return Ok(result); } } Err(Error("Repeated serial response corruption; command was executed once and its result remains in guest /tmp".into())) } pub fn console(mut self) -> Result<()> { let fd = std::io::stdin().as_raw_fd(); let mut saved = std::mem::MaybeUninit::::uninit(); let terminal = unsafe { libc::tcgetattr(fd, saved.as_mut_ptr()) } == 0; struct Restore(Option); impl Drop for Restore { fn drop(&mut self) { if let Some(t) = self.0 { unsafe { libc::tcsetattr(0, libc::TCSANOW, &t); } } } } let original = terminal.then(|| unsafe { saved.assume_init() }); let _restore = Restore(original); if let Some(mut raw) = original { unsafe { libc::cfmakeraw(&mut raw); libc::tcsetattr(fd, libc::TCSANOW, &raw); } } eprintln!("Connected to FDS. Press Ctrl-] to detach; the VM keeps running."); self.socket.set_read_timeout(None)?; self.send("")?; let mut poll = [ libc::pollfd { fd, events: libc::POLLIN, revents: 0, }, libc::pollfd { fd: self.socket.as_raw_fd(), events: libc::POLLIN, revents: 0, }, ]; loop { let rc = unsafe { libc::poll(poll.as_mut_ptr(), 2, -1) }; if rc < 0 { if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { continue; } return Err(std::io::Error::last_os_error().into()); } let mut data = [0u8; 8192]; if poll[0].revents != 0 { let n = std::io::stdin().read(&mut data)?; if n == 0 { return Ok(()); } if let Some(at) = data[..n].iter().position(|b| *b == 29) { self.socket.write_all(&data[..at])?; return Ok(()); } self.socket.write_all(&data[..n])?; } if poll[1].revents != 0 { let n = self.socket.read(&mut data)?; if n == 0 { return Ok(()); } std::io::stdout().write_all(&data[..n])?; std::io::stdout().flush()?; } } } } fn decode(frame: &[u8]) -> Option { let text = std::str::from_utf8(frame).ok()?; let (encoded, tail) = text.split_once("\nHASH ")?; let (hash, status) = tail.split_once("\nSTATUS ")?; let mut bytes = Vec::new(); for pair in encoded.split_whitespace() { if pair.len() != 2 { return None; } bytes.push(u8::from_str_radix(pair, 16).ok()?); } if hash.split_whitespace().next()? != fds_burn::image::hex(&Sha256::digest(&bytes)) { return None; } Some(Output { status: status.trim().parse().ok()?, output: String::from_utf8_lossy(&bytes).into_owned(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn command_results_require_matching_digest() { let hash = fds_burn::image::hex(&Sha256::digest(b"hello\n")); let frame = format!(" 68 65 6c 6c 6f 0a\n\nHASH {hash} /tmp/result\nSTATUS 7"); assert_eq!(decode(frame.as_bytes()).unwrap().status, 7); assert!(decode(frame.replace("68", "69").as_bytes()).is_none()); assert_eq!(quote("a'b $(id)"), "'a'\\''b $(id)'"); } }