FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
//! 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)'");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user