Files
fds-os/rust/fds-workstation/src/serial.rs
T
2026-09-21 22:29:23 +08:00

220 lines
8.8 KiB
Rust

//! 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<u8>,
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<Self> {
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<Vec<u8>> {
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<Output> {
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::<Vec<_>>().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::<libc::termios>::uninit();
let terminal = unsafe { libc::tcgetattr(fd, saved.as_mut_ptr()) } == 0;
struct Restore(Option<libc::termios>);
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<Output> {
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)'");
}
}