220 lines
8.8 KiB
Rust
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)'");
|
|
}
|
|
}
|