FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "fds-workstation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Linux workstation software and cartridge tools"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
fds-common = { path = "../fds-common" }
|
||||
fds-burn = { path = "../fds-burn" }
|
||||
fds-software = { path = "../fds-software" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
sha2 = "=0.10.9"
|
||||
libc = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "fds-cartridge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fds-emulator"
|
||||
path = "src/emulator-main.rs"
|
||||
@@ -0,0 +1,283 @@
|
||||
use crate::{Work, image_tool, parent, success, workstation};
|
||||
use fds_burn::{
|
||||
create,
|
||||
image::{self, Image, Layout},
|
||||
};
|
||||
use fds_common::{
|
||||
Error, Result,
|
||||
manifest::{Cartridge, Class, Manifest, Media},
|
||||
read_text,
|
||||
};
|
||||
use fds_software::{Catalogue, archive};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Payload {
|
||||
bundles: Vec<PathBuf>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Recipe {
|
||||
format: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
payload: Vec<Payload>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Inspection {
|
||||
pub image: Image,
|
||||
pub cartridge: Manifest,
|
||||
pub catalogue: Option<Catalogue>,
|
||||
}
|
||||
pub fn open_image(path: &Path) -> Result<File> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(Error("A prepared image must be a regular file".into()));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
fn uuid(bytes: &[u8]) -> [u8; 16] {
|
||||
let mut id: [u8; 16] = Sha256::digest(bytes)[..16].try_into().unwrap();
|
||||
id[7] = (id[7] & 15) | 0x50;
|
||||
id[8] = (id[8] & 63) | 0x80;
|
||||
id
|
||||
}
|
||||
pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
workstation()?;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Create cartridge images as an ordinary workstation user".into(),
|
||||
));
|
||||
}
|
||||
let plan: Recipe = toml::from_str(&read_text(recipe, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid cartridge recipe: {e}")))?;
|
||||
if plan.format != 1
|
||||
|| !(1..=32).contains(&plan.payload.len())
|
||||
|| plan.payload.iter().any(|p| p.bundles.is_empty())
|
||||
{
|
||||
return Err(Error(
|
||||
"Cartridge recipe requires format 1 and 1..32 nonempty payload partitions".into(),
|
||||
));
|
||||
}
|
||||
let metadata = Manifest {
|
||||
format: 1,
|
||||
cartridge: Cartridge {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
version: plan.version,
|
||||
class: Class::Program,
|
||||
},
|
||||
media: Media { writable: false },
|
||||
activation: None,
|
||||
};
|
||||
metadata.validate()?;
|
||||
let work = Work::new(&parent(output)?)?;
|
||||
let base = parent(recipe)?;
|
||||
let tree = work.0.join("metadata");
|
||||
fs::create_dir_all(tree.join("FDS"))?;
|
||||
fs::write(tree.join("FDS/CARTRIDGE.TOML"), metadata.to_toml()?)?;
|
||||
let mut catalogue = Catalogue {
|
||||
format: 1,
|
||||
software: Vec::new(),
|
||||
};
|
||||
let mut trees = vec![tree.clone()];
|
||||
for (index, part) in plan.payload.iter().enumerate() {
|
||||
let tree = work.0.join(format!("payload{}", index + 2));
|
||||
fs::create_dir_all(tree.join("bundles"))?;
|
||||
for directory in &part.bundles {
|
||||
let directory = base.join(directory).canonicalize()?;
|
||||
let mut entry = crate::software::load(&directory)?;
|
||||
entry.partition = (index + 2) as u8;
|
||||
fs::copy(
|
||||
directory.join(format!("{}.tar.xz", entry.id)),
|
||||
tree.join(entry.archive_path()),
|
||||
)?;
|
||||
catalogue.software.push(entry);
|
||||
}
|
||||
trees.push(tree);
|
||||
}
|
||||
fs::write(tree.join("FDS/SOFTWARE.TOML"), catalogue.to_toml()?)?;
|
||||
let mut files = Vec::new();
|
||||
let mut lengths = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
let mut identity = Vec::new();
|
||||
for (index, tree) in trees.iter().enumerate() {
|
||||
let label = if index == 0 {
|
||||
"FDS_METADATA".into()
|
||||
} else {
|
||||
format!("FDS_PAYLOAD{:02}", index + 1)
|
||||
};
|
||||
let file = work.0.join(format!("part{}.erofs", index + 1));
|
||||
let seed = uuid(format!("{}:{label}", catalogue.to_toml()?).as_bytes());
|
||||
let uuid_text = format!(
|
||||
"{:08x}-{:04x}-{:04x}-{}-{}",
|
||||
image::u32le(&seed, 0),
|
||||
u16::from_le_bytes(seed[4..6].try_into().unwrap()),
|
||||
u16::from_le_bytes(seed[6..8].try_into().unwrap()),
|
||||
image::hex(&seed[8..10]),
|
||||
image::hex(&seed[10..])
|
||||
);
|
||||
success(
|
||||
image_tool(runner, "mkfs.erofs")
|
||||
.args([
|
||||
"--quiet",
|
||||
"-b4096",
|
||||
"-T0",
|
||||
"--all-time",
|
||||
"-x-1",
|
||||
"--all-root",
|
||||
"-U",
|
||||
&uuid_text,
|
||||
"-L",
|
||||
&label,
|
||||
])
|
||||
.arg(&file)
|
||||
.arg(tree)
|
||||
.stdout(Stdio::null()),
|
||||
)?;
|
||||
success(
|
||||
image_tool(runner, "fsck.erofs")
|
||||
.arg("--extract")
|
||||
.arg(&file)
|
||||
.stdout(Stdio::null()),
|
||||
)?;
|
||||
let hash = archive::digest(&File::open(&file)?)?;
|
||||
identity.extend_from_slice(hash.as_bytes());
|
||||
ids.push(uuid(format!("{label}:{hash}").as_bytes()));
|
||||
lengths.push(file.metadata()?.len());
|
||||
files.push(file);
|
||||
}
|
||||
let layout = Layout::software(&lengths, uuid(&identity), &ids)?;
|
||||
let staged = work.0.join("cartridge.img");
|
||||
let mut disk = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&staged)?;
|
||||
layout.write(&disk)?;
|
||||
for (part, file) in layout.partitions.iter().zip(files) {
|
||||
disk.seek(SeekFrom::Start(part.start))?;
|
||||
std::io::copy(&mut File::open(file)?, &mut disk)?;
|
||||
}
|
||||
disk.flush()?;
|
||||
disk.sync_all()?;
|
||||
// Inspect the actual assembled image, not only the source trees.
|
||||
let inspection = inspect(&staged, runner)?;
|
||||
fs::set_permissions(&staged, fs::Permissions::from_mode(0o644))?;
|
||||
fs::hard_link(&staged, output)?;
|
||||
File::open(parent(output)?)?.sync_all()?;
|
||||
Ok(inspection)
|
||||
}
|
||||
pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Inspect untrusted filesystems as an ordinary user before privileged writes".into(),
|
||||
));
|
||||
}
|
||||
let mut file = open_image(path)?;
|
||||
let mut info = image::inspect(&file, file.metadata()?.len())?;
|
||||
if info.filesystem != "erofs" {
|
||||
return Err(Error(
|
||||
"Use fds-burn inspect for DATA geometry; this inspector validates software bundles"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let work = Work::new(&std::env::temp_dir())?;
|
||||
let original = image::digest(&file, info.bytes, |_| Ok(()))?;
|
||||
let mut trees = Vec::new();
|
||||
for part in &info.partitions {
|
||||
let payload = work.0.join(format!("part{}.erofs", part.number));
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&payload)?;
|
||||
file.seek(SeekFrom::Start(part.start))?;
|
||||
if std::io::copy(&mut (&mut file).take(part.bytes), &mut output)? != part.bytes {
|
||||
return Err(Error("Truncated partition".into()));
|
||||
}
|
||||
let tree = work.0.join(format!("tree{}", part.number));
|
||||
success(
|
||||
crate::extract_tool(runner, &work.0)?
|
||||
.arg(format!("--extract={}", tree.display()))
|
||||
.arg(&payload)
|
||||
.stdout(Stdio::null()),
|
||||
)?;
|
||||
trees.push(tree);
|
||||
}
|
||||
let cartridge = create::tree_manifest(&trees[0])?;
|
||||
if cartridge.cartridge.class != info.class {
|
||||
return Err(Error("Cartridge metadata disagrees with GPT".into()));
|
||||
}
|
||||
let catalogue = if info.partitions[0].name == "FDS_METADATA" {
|
||||
let path = trees[0].join("FDS/SOFTWARE.TOML");
|
||||
if !path.symlink_metadata()?.is_file() {
|
||||
return Err(Error("Software catalogue must be a regular file".into()));
|
||||
}
|
||||
let catalogue = Catalogue::parse(&read_text(&path, 65536)?)?;
|
||||
if catalogue.partition_count() != info.partitions.len() {
|
||||
return Err(Error(
|
||||
"Catalogue does not describe every GPT payload partition".into(),
|
||||
));
|
||||
}
|
||||
for software in &catalogue.software {
|
||||
let root = &trees[usize::from(software.partition) - 1];
|
||||
let directory = root.join("bundles");
|
||||
if !directory.symlink_metadata()?.is_dir() {
|
||||
return Err(Error("Bundle directory must not be a symlink".into()));
|
||||
}
|
||||
archive::verify(
|
||||
&archive::open(&root.join(software.archive_path()))?,
|
||||
software,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
// Reject unlisted files or software hidden in a payload partition.
|
||||
for (index, root) in trees.iter().enumerate().skip(1) {
|
||||
if fs::read_dir(root)?.count() != 1 {
|
||||
return Err(Error("Payload partitions may contain only bundles/".into()));
|
||||
}
|
||||
let mut actual: Vec<_> = fs::read_dir(root.join("bundles"))?
|
||||
.map(|e| e.map(|e| e.file_name()))
|
||||
.collect::<std::io::Result<_>>()?;
|
||||
let mut expected: Vec<_> = catalogue
|
||||
.software
|
||||
.iter()
|
||||
.filter(|s| usize::from(s.partition) == index + 1)
|
||||
.map(|s| std::ffi::OsString::from(format!("{}.tar.xz", s.id)))
|
||||
.collect();
|
||||
actual.sort();
|
||||
expected.sort();
|
||||
if actual != expected {
|
||||
return Err(Error(
|
||||
"Payload archive inventory disagrees with metadata".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(catalogue)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if image::digest(&file, info.bytes, |_| Ok(()))? != original {
|
||||
return Err(Error("Image changed while being inspected".into()));
|
||||
}
|
||||
info.sha256 = Some(original);
|
||||
Ok(Inspection {
|
||||
image: info,
|
||||
cartridge,
|
||||
catalogue,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Explicit dependency checks; no packages are installed by these commands.
|
||||
use fds_common::{Error, Result};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::Path, process::Command};
|
||||
fn version(command: &mut Command) -> Result<String> {
|
||||
let result = command
|
||||
.output()
|
||||
.map_err(|e| Error(format!("Cannot run {command:?}: {e}")))?;
|
||||
if !result.status.success() {
|
||||
return Err(Error(format!(
|
||||
"{command:?} failed: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&result.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_owned())
|
||||
}
|
||||
pub fn cartridge(runner: Option<&Path>) -> Result<Value> {
|
||||
crate::workstation()?;
|
||||
let xz = version(Command::new("xz").arg("--version"))?;
|
||||
let mkfs = version(crate::image_tool(runner, "mkfs.erofs").arg("-V"))?;
|
||||
let fsck = version(crate::image_tool(runner, "fsck.erofs").arg("-V"))?;
|
||||
let sandbox = Command::new("bwrap")
|
||||
.args([
|
||||
"--unshare-user",
|
||||
"--unshare-net",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--",
|
||||
"true",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
Error(format!(
|
||||
"bubblewrap is required to inspect untrusted filesystems: {e}"
|
||||
))
|
||||
})?;
|
||||
if !sandbox.status.success() {
|
||||
return Err(Error(format!(
|
||||
"Unprivileged bubblewrap namespaces are unavailable: {}",
|
||||
String::from_utf8_lossy(&sandbox.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(
|
||||
json!({"xz":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","cross_compiler":"recipe-specific; use aarch64 output or architecture=any scripts"}),
|
||||
)
|
||||
}
|
||||
pub fn emulator(runner: Option<&Path>) -> Result<Value> {
|
||||
crate::workstation()?;
|
||||
Ok(
|
||||
json!({"qemu-system-aarch64":version(crate::image_tool(runner,"qemu-system-aarch64").arg("--version"))?,"qemu-img":version(crate::image_tool(runner,"qemu-img").arg("--version"))?,"acceleration":"portable TCG; no KVM requirement"}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use fds_common::{Bay, Result};
|
||||
use fds_workstation::emulator::Session;
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Boot FDS/OS and hotplug virtual USB cartridges on a Linux workstation",
|
||||
after_help = "QEMU virt tests FDS software, not Raspberry Pi firmware or physical hardware. DATA uses retained temporary overlays; source images remain unchanged."
|
||||
)]
|
||||
struct Cli {
|
||||
/// Private session directory. Choose a new directory for each boot.
|
||||
#[arg(long, global = true, default_value = "out/emulator")]
|
||||
session: PathBuf,
|
||||
#[command(subcommand)]
|
||||
command: Action,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// Check workstation QEMU tools without creating or starting a VM.
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
qemu_runner: Option<PathBuf>,
|
||||
},
|
||||
/// Boot the supplied FDS kernel, initramfs and SYSTEM; wait for its user console.
|
||||
Start {
|
||||
#[arg(long, default_value = "out/kernel/boot/kernel_2712.img")]
|
||||
kernel: PathBuf,
|
||||
#[arg(long, default_value = "out/fds-initramfs.img")]
|
||||
initramfs: PathBuf,
|
||||
#[arg(long, default_value = "out/fds-system-cli.img")]
|
||||
system: PathBuf,
|
||||
/// Optional wrapper accepting qemu-system-aarch64 or qemu-img plus arguments.
|
||||
#[arg(long)]
|
||||
qemu_runner: Option<PathBuf>,
|
||||
/// Guest RAM in MiB; emulation uses two CPU cores with TCG.
|
||||
#[arg(long, default_value_t=1024, value_parser=clap::value_parser!(u32).range(512..=32768))]
|
||||
memory_mib: u32,
|
||||
#[arg(long, default_value_t=180, value_parser=clap::value_parser!(u64).range(1..=1800))]
|
||||
timeout: u64,
|
||||
},
|
||||
/// Report actual QEMU state, devices, and retained image/overlay locations.
|
||||
Status,
|
||||
/// Attach the ordinary FDS console; Ctrl-] detaches without stopping QEMU.
|
||||
Console,
|
||||
/// Execute one ordinary-user guest command; arguments are passed literally.
|
||||
Guest {
|
||||
#[arg(long, default_value_t=120, value_parser=clap::value_parser!(u64).range(1..=1800))]
|
||||
timeout: u64,
|
||||
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
/// Insert a regular cartridge disk image into bay 01 through 12.
|
||||
Insert { bay: Bay, image: PathBuf },
|
||||
/// Ask FDS to stop users and declare SAFE, then remove the virtual USB device.
|
||||
Eject { bay: Bay },
|
||||
/// Simulate pulling a cartridge without guest eject; writable data may be lost.
|
||||
Unplug { bay: Bay },
|
||||
/// Request native FDS shutdown; --force instead cuts virtual power immediately.
|
||||
Stop {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
fn run() -> Result<u8> {
|
||||
let cli = Cli::parse();
|
||||
let value = match cli.command {
|
||||
Action::Doctor { qemu_runner } => {
|
||||
fds_workstation::doctor::emulator(qemu_runner.as_deref())?
|
||||
}
|
||||
Action::Start {
|
||||
kernel,
|
||||
initramfs,
|
||||
system,
|
||||
qemu_runner,
|
||||
memory_mib,
|
||||
timeout,
|
||||
} => Session::start(
|
||||
&cli.session,
|
||||
&kernel,
|
||||
&initramfs,
|
||||
&system,
|
||||
qemu_runner.as_deref(),
|
||||
memory_mib,
|
||||
timeout,
|
||||
)?,
|
||||
action => {
|
||||
let mut session = Session::load(&cli.session)?;
|
||||
match action {
|
||||
Action::Status => session.status()?,
|
||||
Action::Console => {
|
||||
session.console()?;
|
||||
return Ok(0);
|
||||
}
|
||||
Action::Guest { timeout, arguments } => {
|
||||
let result = session.guest(&arguments, timeout)?;
|
||||
print!("{}", result.output);
|
||||
return Ok(result.status);
|
||||
}
|
||||
Action::Insert { bay, image } => session.insert(bay, &image)?,
|
||||
Action::Eject { bay } => session.remove(bay, false)?,
|
||||
Action::Unplug { bay } => session.remove(bay, true)?,
|
||||
Action::Stop { force } => session.stop(force)?,
|
||||
Action::Start { .. } | Action::Doctor { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&value).unwrap());
|
||||
Ok(0)
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
eprintln!("fds-emulator: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
#[test]
|
||||
fn clap_preserves_guest_options_and_bounds_hardware() {
|
||||
Cli::command().debug_assert();
|
||||
let value =
|
||||
Cli::try_parse_from(["fds-emulator", "guest", "--", "fds", "--json", "bays"]).unwrap();
|
||||
assert!(
|
||||
matches!(value.command,Action::Guest { arguments,.. } if arguments == ["fds","--json","bays"])
|
||||
);
|
||||
assert!(Cli::try_parse_from(["fds-emulator", "insert", "13", "file.img"]).is_err());
|
||||
assert!(Cli::try_parse_from(["fds-emulator", "start", "--memory-mib", "0"]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Persistent QEMU sessions with QMP hotplug and ordinary-user guest control.
|
||||
use crate::{
|
||||
qmp::Qmp,
|
||||
serial::{Output, Serial},
|
||||
};
|
||||
use fds_burn::image;
|
||||
use fds_common::{Bay, Error, Result, manifest::Class};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Write,
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Cartridge {
|
||||
pub image: PathBuf,
|
||||
pub class: Class,
|
||||
pub overlay: Option<PathBuf>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct State {
|
||||
pub format: u8,
|
||||
pub name: String,
|
||||
pub kernel: PathBuf,
|
||||
pub initramfs: PathBuf,
|
||||
pub system: PathBuf,
|
||||
pub qemu_runner: Option<PathBuf>,
|
||||
pub cartridges: BTreeMap<u8, Cartridge>,
|
||||
}
|
||||
pub struct Session {
|
||||
root: PathBuf,
|
||||
_lock: File,
|
||||
state: State,
|
||||
}
|
||||
fn ordinary() -> Result<()> {
|
||||
crate::workstation()?;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Run the emulator as an ordinary workstation user".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn regular(path: &Path) -> Result<PathBuf> {
|
||||
let resolved = path.canonicalize()?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(&resolved)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(Error(format!(
|
||||
"{} must be a regular file, never a physical drive",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
fn inspect(path: &Path) -> Result<image::Image> {
|
||||
let file = File::open(path)?;
|
||||
image::inspect(&file, file.metadata()?.len())
|
||||
}
|
||||
fn lock(root: &Path) -> Result<File> {
|
||||
let meta = fs::symlink_metadata(root)?;
|
||||
if !meta.is_dir() || meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 {
|
||||
return Err(Error(
|
||||
"Session directory must be private (0700) and owned by you".into(),
|
||||
));
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(root.join("control.lock"))?;
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return Err(Error(
|
||||
"Another emulator control operation is in progress".into(),
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
impl Session {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
ordinary()?;
|
||||
let guard = lock(root)?;
|
||||
let state: State =
|
||||
serde_json::from_str(&fds_common::read_text(&root.join("session.json"), 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid emulator session: {e}")))?;
|
||||
if state.format != 1
|
||||
|| !state.name.starts_with("fds-")
|
||||
|| state.cartridges.keys().any(|b| !(1..=12).contains(b))
|
||||
{
|
||||
return Err(Error("Unsupported emulator session".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
root: root.canonicalize()?,
|
||||
_lock: guard,
|
||||
state,
|
||||
})
|
||||
}
|
||||
fn save(&self) -> Result<()> {
|
||||
let path = self.root.join("session.next");
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&path)?;
|
||||
file.write_all(&serde_json::to_vec_pretty(&self.state).map_err(|e| Error(e.to_string()))?)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(path, self.root.join("session.json"))?;
|
||||
File::open(&self.root)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn qmp(&self) -> Result<Qmp> {
|
||||
let mut qmp = Qmp::connect(&self.root.join("qmp.sock"))?;
|
||||
if qmp.execute("query-name", json!({}))?["name"] != self.state.name {
|
||||
return Err(Error(
|
||||
"QEMU session identity mismatch; no operation performed".into(),
|
||||
));
|
||||
}
|
||||
Ok(qmp)
|
||||
}
|
||||
pub fn start(
|
||||
root: &Path,
|
||||
kernel: &Path,
|
||||
initramfs: &Path,
|
||||
system: &Path,
|
||||
runner: Option<&Path>,
|
||||
memory: u32,
|
||||
timeout: u64,
|
||||
) -> Result<Value> {
|
||||
ordinary()?;
|
||||
let kernel = regular(kernel)?;
|
||||
let initramfs = regular(initramfs)?;
|
||||
let system = regular(system)?;
|
||||
if inspect(&system)?.class != Class::System {
|
||||
return Err(Error("Boot image must be an FDS SYSTEM cartridge".into()));
|
||||
}
|
||||
let root = crate::parent(root)?.join(
|
||||
root.file_name()
|
||||
.ok_or_else(|| Error("Missing session directory name".into()))?,
|
||||
);
|
||||
if root.as_os_str().len() + 16 >= 108 || root.to_string_lossy().contains([',', '\n', '\r'])
|
||||
{
|
||||
return Err(Error(
|
||||
"Use a shorter session path (under 90 bytes), without commas or line breaks".into(),
|
||||
));
|
||||
}
|
||||
fs::DirBuilder::new()
|
||||
.mode(0o700)
|
||||
.create(&root)
|
||||
.map_err(|e| {
|
||||
Error(format!(
|
||||
"Create a new session directory {}: {e}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
let session = Self {
|
||||
_lock: lock(&root)?,
|
||||
root,
|
||||
state: State {
|
||||
format: 1,
|
||||
name: format!("fds-{}", image::hex(&image::random_id()?)),
|
||||
kernel,
|
||||
initramfs,
|
||||
system,
|
||||
qemu_runner: runner.map(regular).transpose()?,
|
||||
cartridges: BTreeMap::new(),
|
||||
},
|
||||
};
|
||||
session.save()?;
|
||||
let mut command =
|
||||
crate::image_tool(session.state.qemu_runner.as_deref(), "qemu-system-aarch64");
|
||||
let block = json!({"driver":"raw","node-name":"system","read-only":true,"file":{"driver":"file","filename":session.state.system}});
|
||||
command.args(["-machine","virt","-cpu","max","-accel","tcg","-m", &memory.to_string(),"-smp","2","-nodefaults","-display","none","-nic","none","-no-reboot","-daemonize","-S","-name",&session.state.name,"-pidfile"])
|
||||
.arg(session.root.join("qemu.pid"))
|
||||
.arg("-qmp").arg(format!("unix:{}/qmp.sock,server=on,wait=off", session.root.display()))
|
||||
.arg("-chardev").arg(format!("socket,id=console,path={}/console.sock,server=on,wait=off,logfile={}/console.log,logappend=on", session.root.display(),session.root.display()))
|
||||
.args(["-serial","chardev:console","-kernel"]).arg(&session.state.kernel)
|
||||
.arg("-initrd").arg(&session.state.initramfs)
|
||||
.args(["-append","console=ttyAMA0 rdinit=/init ro quiet loglevel=3 fds.emulator=1","-blockdev"]).arg(block.to_string())
|
||||
.args(["-device","virtio-blk-pci,drive=system","-device","qemu-xhci,id=xhci,addr=05.0,p2=12,p3=12"])
|
||||
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(File::create(session.root.join("qemu.log"))?);
|
||||
crate::success(&mut command)
|
||||
.map_err(|e| Error(format!("{e}; inspect {}/qemu.log", session.root.display())))?;
|
||||
let boot = (|| -> Result<()> {
|
||||
let mut qmp = session.qmp()?;
|
||||
let mut console = Serial::connect(&session.root, timeout)?;
|
||||
qmp.execute("cont", json!({}))?;
|
||||
drop(qmp);
|
||||
console.until(b"FDS> ")?;
|
||||
let settings = console.command(&[
|
||||
"fds".into(),
|
||||
"--json".into(),
|
||||
"machine".into(),
|
||||
"status".into(),
|
||||
])?;
|
||||
let value: Value = serde_json::from_str(&settings.output)
|
||||
.map_err(|e| Error(format!("Guest machine settings: {e}: {}", settings.output)))?;
|
||||
if settings.status != 0 || value["source"] != "qemu_emulator" {
|
||||
return Err(Error("SYSTEM image lacks emulator bay configuration; rebuild it with make rootfs PROFILE=cli and make system-card PROFILE=cli".into()));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = boot {
|
||||
if let Ok(mut qmp) = session.qmp() {
|
||||
let _ = qmp.execute("quit", json!({}));
|
||||
}
|
||||
return Err(Error(format!(
|
||||
"Emulator boot failed: {error}; inspect {}/console.log",
|
||||
session.root.display()
|
||||
)));
|
||||
}
|
||||
session.status()
|
||||
}
|
||||
pub fn status(&self) -> Result<Value> {
|
||||
let mut qmp = self.qmp()?;
|
||||
Ok(
|
||||
json!({"session":self.root,"state":self.state,"qemu":qmp.execute("query-status",json!({}))?,"devices":qmp.execute("qom-list",json!({"path":"/machine/peripheral"}))?,"block_nodes":qmp.execute("query-named-block-nodes",json!({"flat":true}))?}),
|
||||
)
|
||||
}
|
||||
pub fn guest(&self, args: &[String], timeout: u64) -> Result<Output> {
|
||||
self.qmp()?;
|
||||
Serial::connect(&self.root, timeout)?.command(args)
|
||||
}
|
||||
pub fn console(self) -> Result<()> {
|
||||
self.qmp()?;
|
||||
// Interactive use must not block QMP forced unplug from another terminal.
|
||||
let console = Serial::connect(&self.root, 120)?;
|
||||
drop(self._lock);
|
||||
console.console()
|
||||
}
|
||||
pub fn insert(&mut self, bay: Bay, path: &Path) -> Result<Value> {
|
||||
let number = bay.number();
|
||||
if self.state.cartridges.contains_key(&number) {
|
||||
return Err(Error(
|
||||
"Bay is occupied or has an incomplete insertion; eject or unplug it first".into(),
|
||||
));
|
||||
}
|
||||
let path = regular(path)?;
|
||||
let info = inspect(&path)?;
|
||||
let mut qmp = self.qmp()?;
|
||||
let node = format!("disk{bay}");
|
||||
let id = format!("cart{bay}");
|
||||
let mut cartridge = Cartridge {
|
||||
image: path.clone(),
|
||||
class: info.class,
|
||||
overlay: None,
|
||||
};
|
||||
if info.class == Class::Data {
|
||||
let overlay = self.root.join(format!(
|
||||
"data-{bay}-{}.qcow2",
|
||||
image::hex(&image::random_id()?)
|
||||
));
|
||||
crate::success(
|
||||
crate::image_tool(self.state.qemu_runner.as_deref(), "qemu-img")
|
||||
.args(["create", "-q", "-f", "qcow2", "-F", "raw", "-b"])
|
||||
.arg(&path)
|
||||
.arg(&overlay),
|
||||
)?;
|
||||
cartridge.overlay = Some(overlay);
|
||||
}
|
||||
let backing =
|
||||
json!({"driver":"raw","read-only":true,"file":{"driver":"file","filename":path}});
|
||||
let block = if let Some(overlay) = &cartridge.overlay {
|
||||
json!({"driver":"qcow2","node-name":node,"file":{"driver":"file","filename":overlay},"backing":backing})
|
||||
} else {
|
||||
json!({"driver":"raw","node-name":node,"read-only":true,"file":{"driver":"file","filename":path}})
|
||||
};
|
||||
// Persist intent before changing QEMU so an interrupted client can recover
|
||||
// with unplug, including a block node left between add and device_add.
|
||||
self.state.cartridges.insert(number, cartridge);
|
||||
self.save()?;
|
||||
if let Err(error) = qmp.execute("blockdev-add", block).and_then(|_| qmp.execute("device_add", json!({"driver":"usb-storage","id":id,"drive":node,"bus":"xhci.0","port":number.to_string(),"serial":format!("FDS-{bay}"),"removable":true}))) {
|
||||
let _ = qmp.execute("blockdev-del", json!({"node-name":node}));
|
||||
// Retain intent: unplug reconciles actual QMP state after a timeout.
|
||||
return Err(Error(format!("{error}; run unplug {bay} to clean up the incomplete insertion")));
|
||||
}
|
||||
Ok(
|
||||
json!({"bay":number,"inserted":self.state.cartridges[&number],"guest_detection":"asynchronous; use guest -- fds bay BAY"}),
|
||||
)
|
||||
}
|
||||
pub fn remove(&mut self, bay: Bay, force: bool) -> Result<Value> {
|
||||
let number = bay.number();
|
||||
if !self.state.cartridges.contains_key(&number) {
|
||||
return Err(Error("No cartridge is recorded in that bay".into()));
|
||||
}
|
||||
let id = format!("cart{bay}");
|
||||
let node = format!("disk{bay}");
|
||||
let mut qmp = self.qmp()?;
|
||||
let devices = qmp.execute("qom-list", json!({"path":"/machine/peripheral"}))?;
|
||||
let present = devices
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().any(|d| d["name"] == id));
|
||||
if present {
|
||||
if !force {
|
||||
let response = Serial::connect(&self.root, 120)?.command(&[
|
||||
"fds".into(),
|
||||
"--json".into(),
|
||||
"eject".into(),
|
||||
bay.to_string(),
|
||||
])?;
|
||||
let report: Value = serde_json::from_str(&response.output)
|
||||
.map_err(|_| Error(format!("Guest eject failed: {}", response.output)))?;
|
||||
if response.status != 0
|
||||
|| report["bays"].as_array().is_none_or(|a| {
|
||||
a.len() != 1 || a[0]["state"] != "safe" || a[0]["bay"] != number
|
||||
})
|
||||
{
|
||||
return Err(Error(format!(
|
||||
"Guest has not declared bay {bay} SAFE: {}",
|
||||
response.output
|
||||
)));
|
||||
}
|
||||
}
|
||||
qmp.execute("device_del", json!({"id":id}))?;
|
||||
qmp.deleted(&id)?;
|
||||
}
|
||||
let nodes = qmp.execute("query-named-block-nodes", json!({"flat":true}))?;
|
||||
if nodes
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().any(|n| n["node-name"] == node))
|
||||
{
|
||||
qmp.execute("blockdev-del", json!({"node-name":node}))?;
|
||||
}
|
||||
let removed = self.state.cartridges.remove(&number).unwrap();
|
||||
self.save()?;
|
||||
Ok(
|
||||
json!({"bay":number,"removed":removed,"mode":if force {"forced_unplug"} else {"safe_eject"},"overlay_retained":true}),
|
||||
)
|
||||
}
|
||||
pub fn stop(&self, force: bool) -> Result<Value> {
|
||||
let mut qmp = self.qmp()?;
|
||||
if force {
|
||||
qmp.execute("quit", json!({}))?;
|
||||
} else {
|
||||
let output =
|
||||
Serial::connect(&self.root, 120)?.command(&["fds".into(), "poweroff".into()])?;
|
||||
if output.status != 0 {
|
||||
return Err(Error(format!("Guest refused shutdown: {}", output.output)));
|
||||
}
|
||||
}
|
||||
qmp.closed()?;
|
||||
Ok(json!({"stopped":self.root,"forced":force,"logs_and_data_overlays_retained":true}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Host tools use normal Linux programs found in PATH, not a target OS service.
|
||||
pub mod cartridge;
|
||||
pub mod doctor;
|
||||
pub mod emulator;
|
||||
mod qmp;
|
||||
mod serial;
|
||||
pub mod software;
|
||||
pub mod writing;
|
||||
|
||||
use fds_common::{Error, Result};
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::DirBuilderExt,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
pub fn workstation() -> Result<()> {
|
||||
if Path::new("/usr/share/fds/image-profile").exists()
|
||||
|| fs::read_to_string("/proc/device-tree/model").is_ok_and(|s| s.contains("Raspberry Pi"))
|
||||
{
|
||||
return Err(Error("Build software and cartridges on a Linux workstation, not the Raspberry Pi/FDS runtime".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub struct Work(pub PathBuf);
|
||||
impl Work {
|
||||
pub fn new(parent: &Path) -> Result<Self> {
|
||||
let path = parent.join(format!(
|
||||
".fds-work-{}",
|
||||
fds_burn::image::hex(&fds_burn::image::random_id()?)
|
||||
));
|
||||
fs::DirBuilder::new().mode(0o700).create(&path)?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
}
|
||||
impl Drop for Work {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
pub fn parent(path: &Path) -> Result<PathBuf> {
|
||||
Ok(path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."))
|
||||
.canonicalize()?)
|
||||
}
|
||||
pub fn success(command: &mut Command) -> Result<()> {
|
||||
let description = format!("{command:?}");
|
||||
let status = command
|
||||
.status()
|
||||
.map_err(|e| Error(format!("Cannot run {description}: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error(format!("Command failed ({status}): {description}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Optional explicit runner supports the project's existing image-tool prefix.
|
||||
/// Without it, the installed Linux erofs-utils commands are used directly.
|
||||
pub fn image_tool(runner: Option<&Path>, program: &str) -> Command {
|
||||
if let Some(runner) = runner {
|
||||
let mut command = Command::new(runner);
|
||||
command.arg(program);
|
||||
command
|
||||
} else {
|
||||
Command::new(program)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem extraction can write only its private staging directory. The
|
||||
/// workstation and project remain read-only even for malformed filesystem data.
|
||||
pub fn extract_tool(runner: Option<&Path>, work: &Path) -> Result<Command> {
|
||||
// Extraction changes directory inside the sandbox; resolve a caller-relative
|
||||
// wrapper before entering it. Tool arguments remain separate argv entries.
|
||||
let runner = runner.map(fs::canonicalize).transpose()?;
|
||||
let tool = image_tool(runner.as_deref(), "fsck.erofs");
|
||||
let mut command = Command::new("bwrap");
|
||||
command
|
||||
.args([
|
||||
"--unshare-user",
|
||||
"--unshare-net",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
])
|
||||
.arg("--bind")
|
||||
.arg(work)
|
||||
.arg(work)
|
||||
.arg("--chdir")
|
||||
.arg(work)
|
||||
.arg(tool.get_program())
|
||||
.args(tool.get_args());
|
||||
Ok(command)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use fds_common::Result;
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Build software bundles and metadata-first cartridge images on Linux"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Optional wrapper that accepts TOOL followed by its arguments.
|
||||
#[arg(long, global = true)]
|
||||
image_tool_runner: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Action,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// Check xz, erofs-utils and the unprivileged filesystem inspection sandbox.
|
||||
Doctor,
|
||||
/// Build or package software on the workstation, never on the Pi.
|
||||
Software {
|
||||
#[command(subcommand)]
|
||||
command: Software,
|
||||
},
|
||||
/// Construct and verify a complete 1+m GPT cartridge image before burning.
|
||||
Create { recipe: PathBuf, output: PathBuf },
|
||||
/// Verify GPT, every filesystem, catalogue and xz tarball in a prepared image.
|
||||
Inspect { image: PathBuf },
|
||||
/// Verify a prepared image and save an image/target-bound write preview.
|
||||
Preview {
|
||||
image: PathBuf,
|
||||
target: PathBuf,
|
||||
output: PathBuf,
|
||||
/// Use an existing disposable regular file instead of a physical USB drive.
|
||||
#[arg(long)]
|
||||
file_target: bool,
|
||||
},
|
||||
/// Write the entire prepared image only after exact preview confirmation.
|
||||
Write {
|
||||
preview: PathBuf,
|
||||
#[arg(long)]
|
||||
confirm: String,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Software {
|
||||
/// Execute an explicit trusted workstation build recipe, then package its output.
|
||||
Build { recipe: PathBuf, output: PathBuf },
|
||||
/// Package an existing software tree without running its build command.
|
||||
Pack { recipe: PathBuf, output: PathBuf },
|
||||
/// Check a built software descriptor, archive digest and extracted contents.
|
||||
Inspect { directory: PathBuf },
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let result = match cli.command {
|
||||
Action::Doctor => Ok(fds_workstation::doctor::cartridge(
|
||||
cli.image_tool_runner.as_deref(),
|
||||
)?),
|
||||
Action::Preview {
|
||||
image,
|
||||
target,
|
||||
output,
|
||||
file_target,
|
||||
} => serde_json::to_value(fds_workstation::writing::preview(
|
||||
&image,
|
||||
&target,
|
||||
&output,
|
||||
file_target,
|
||||
cli.image_tool_runner.as_deref(),
|
||||
)?),
|
||||
Action::Write { preview, confirm } => {
|
||||
fds_workstation::writing::write(&preview, &confirm)?;
|
||||
return Ok(());
|
||||
}
|
||||
Action::Software { command } => serde_json::to_value(match command {
|
||||
Software::Build { recipe, output } => {
|
||||
fds_workstation::software::build(&recipe, &output, true)?
|
||||
}
|
||||
Software::Pack { recipe, output } => {
|
||||
fds_workstation::software::build(&recipe, &output, false)?
|
||||
}
|
||||
Software::Inspect { directory } => fds_workstation::software::load(&directory)?,
|
||||
}),
|
||||
Action::Create { recipe, output } => serde_json::to_value(
|
||||
fds_workstation::cartridge::create(&recipe, &output, cli.image_tool_runner.as_deref())?,
|
||||
),
|
||||
Action::Inspect { image } => serde_json::to_value(fds_workstation::cartridge::inspect(
|
||||
&image,
|
||||
cli.image_tool_runner.as_deref(),
|
||||
)?),
|
||||
}
|
||||
.map_err(|e| fds_common::Error(e.to_string()))?;
|
||||
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("fds-cartridge: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
#[test]
|
||||
fn typed_grammar() {
|
||||
Cli::command().debug_assert();
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"fds-cartridge",
|
||||
"software",
|
||||
"build",
|
||||
"recipe.toml",
|
||||
"bundle"
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"fds-cartridge",
|
||||
"create",
|
||||
"recipe.toml",
|
||||
"card.img",
|
||||
"--force"
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert!(Cli::try_parse_from(["fds-cartridge", "create", "recipe.toml"]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Bounded QMP transport; command replies and asynchronous events are distinct.
|
||||
use fds_common::{Error, Result};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{BufRead, BufReader, Write},
|
||||
os::unix::net::UnixStream,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
pub struct Qmp {
|
||||
reader: BufReader<UnixStream>,
|
||||
events: VecDeque<Value>,
|
||||
sequence: u64,
|
||||
}
|
||||
impl Qmp {
|
||||
pub fn connect(path: &Path) -> Result<Self> {
|
||||
let socket = UnixStream::connect(path).map_err(|e| {
|
||||
Error(format!(
|
||||
"QEMU control unavailable at {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
socket.set_write_timeout(Some(Duration::from_secs(10)))?;
|
||||
let mut result = Self {
|
||||
reader: BufReader::new(socket),
|
||||
events: VecDeque::new(),
|
||||
sequence: 0,
|
||||
};
|
||||
loop {
|
||||
let value = result.read()?;
|
||||
if value.get("QMP").is_some() {
|
||||
break;
|
||||
}
|
||||
result.event(value)?;
|
||||
}
|
||||
result.execute("qmp_capabilities", json!({}))?;
|
||||
Ok(result)
|
||||
}
|
||||
fn read(&mut self) -> Result<Value> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let data = self.reader.fill_buf()?;
|
||||
if data.is_empty() {
|
||||
return Err(Error("QEMU control connection closed".into()));
|
||||
}
|
||||
let count = data
|
||||
.iter()
|
||||
.position(|b| *b == b'\n')
|
||||
.map_or(data.len(), |n| n + 1);
|
||||
line.extend_from_slice(&data[..count]);
|
||||
self.reader.consume(count);
|
||||
if line.len() > 4 * 1024 * 1024 {
|
||||
return Err(Error("QMP reply exceeds 4 MiB".into()));
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
serde_json::from_slice(&line).map_err(|e| Error(format!("Invalid QMP reply: {e}")))
|
||||
}
|
||||
fn event(&mut self, value: Value) -> Result<()> {
|
||||
if value.get("event").is_none() {
|
||||
return Err(Error(format!("Unexpected QMP message: {value}")));
|
||||
}
|
||||
if self.events.len() >= 1024 {
|
||||
return Err(Error("QMP event queue overflow".into()));
|
||||
}
|
||||
self.events.push_back(value);
|
||||
Ok(())
|
||||
}
|
||||
pub fn execute(&mut self, command: &str, arguments: Value) -> Result<Value> {
|
||||
self.sequence += 1;
|
||||
let id = self.sequence;
|
||||
let mut data =
|
||||
serde_json::to_vec(&json!({"execute":command,"arguments":arguments,"id":id})).unwrap();
|
||||
data.push(b'\n');
|
||||
self.reader.get_mut().write_all(&data)?;
|
||||
loop {
|
||||
let value = self.read()?;
|
||||
if value.get("event").is_some() {
|
||||
self.event(value)?;
|
||||
continue;
|
||||
}
|
||||
if value["id"] != id {
|
||||
return Err(Error("QMP response id mismatch".into()));
|
||||
}
|
||||
if let Some(error) = value.get("error") {
|
||||
return Err(Error(format!("QEMU {command}: {error}")));
|
||||
}
|
||||
return value
|
||||
.get("return")
|
||||
.cloned()
|
||||
.ok_or_else(|| Error("QMP response has no result".into()));
|
||||
}
|
||||
}
|
||||
pub fn deleted(&mut self, device: &str) -> Result<()> {
|
||||
loop {
|
||||
while let Some(event) = self.events.pop_front() {
|
||||
if event["data"]["device"] == device
|
||||
|| event["data"]["path"]
|
||||
.as_str()
|
||||
.is_some_and(|p| p == format!("/machine/peripheral/{device}"))
|
||||
{
|
||||
if event["event"] == "DEVICE_DELETED" {
|
||||
return Ok(());
|
||||
}
|
||||
if event["event"] == "DEVICE_UNPLUG_GUEST_ERROR" {
|
||||
return Err(Error("Guest refused device removal".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = self.read()?;
|
||||
self.event(value)?;
|
||||
}
|
||||
}
|
||||
pub fn closed(&mut self) -> Result<()> {
|
||||
loop {
|
||||
match self.read() {
|
||||
Err(Error(e)) if e == "QEMU control connection closed" => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
Ok(value) => self.event(value)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::{Work, parent, workstation};
|
||||
use fds_common::{Error, Result, manifest::identifier, read_text};
|
||||
use fds_software::{Software, archive};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Build {
|
||||
directory: PathBuf,
|
||||
command: Vec<String>,
|
||||
#[serde(default)]
|
||||
environment: BTreeMap<String, String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Recipe {
|
||||
format: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
architecture: String,
|
||||
root: PathBuf,
|
||||
commands: BTreeMap<String, String>,
|
||||
build: Option<Build>,
|
||||
}
|
||||
pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
|
||||
workstation()?;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Run software builds as an ordinary workstation user".into(),
|
||||
));
|
||||
}
|
||||
if output.try_exists()? {
|
||||
return Err(Error("Software output already exists".into()));
|
||||
}
|
||||
let input: Recipe = toml::from_str(&read_text(recipe, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid build recipe: {e}")))?;
|
||||
if input.format != 1 || !identifier(&input.id) {
|
||||
return Err(Error(
|
||||
"Build recipe requires format 1 and a valid software id".into(),
|
||||
));
|
||||
}
|
||||
let base = parent(recipe)?;
|
||||
if compile {
|
||||
let build = input.build.ok_or_else(|| {
|
||||
Error("Build recipe lacks [build]; use software pack for an existing tree".into())
|
||||
})?;
|
||||
let program = build
|
||||
.command
|
||||
.first()
|
||||
.ok_or_else(|| Error("Build command is empty".into()))?;
|
||||
let directory = base.join(build.directory).canonicalize()?;
|
||||
crate::success(
|
||||
Command::new(program)
|
||||
.args(&build.command[1..])
|
||||
.current_dir(directory)
|
||||
.envs(build.environment)
|
||||
.env("FDS_TARGET_ARCH", &input.architecture)
|
||||
.stdin(Stdio::null()),
|
||||
)?;
|
||||
}
|
||||
let root = base.join(input.root).canonicalize()?;
|
||||
let output_parent = parent(output)?;
|
||||
if output_parent.starts_with(&root) {
|
||||
return Err(Error(
|
||||
"Software output must be outside its source tree".into(),
|
||||
));
|
||||
}
|
||||
let work = Work::new(&output_parent)?;
|
||||
let bundle = work.0.join("bundle");
|
||||
fs::create_dir(&bundle)?;
|
||||
let archive_path = bundle.join(format!("{}.tar.xz", input.id));
|
||||
let encoded = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o644)
|
||||
.open(&archive_path)?;
|
||||
let mut child = Command::new("xz")
|
||||
.args(["--compress", "--stdout", "--threads=1", "-6"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::from(encoded))
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
let stats = archive::write_tar(&root, child.stdin.take().unwrap(), &input.architecture);
|
||||
if stats.is_err() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let status = child.wait()?;
|
||||
let stats = stats?;
|
||||
if !status.success() {
|
||||
return Err(Error("XZ compression failed".into()));
|
||||
}
|
||||
let file = archive::open(&archive_path)?;
|
||||
let software = Software {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
version: input.version,
|
||||
architecture: input.architecture,
|
||||
partition: 2,
|
||||
archive_bytes: file.metadata()?.len(),
|
||||
unpacked_bytes: stats.bytes,
|
||||
entries: stats.entries,
|
||||
sha256: archive::digest(&file)?,
|
||||
commands: input.commands,
|
||||
};
|
||||
archive::verify(&file, &software, None)?;
|
||||
let descriptor = toml::to_string(&software).map_err(|e| Error(e.to_string()))?;
|
||||
fs::write(bundle.join("software.toml"), descriptor)?;
|
||||
fs::set_permissions(&bundle, fs::Permissions::from_mode(0o755))?;
|
||||
// renameat2(NO_REPLACE) publishes the complete directory without overwriting.
|
||||
let from = std::ffi::CString::new(bundle.as_os_str().as_encoded_bytes())
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
let to = std::ffi::CString::new(output.as_os_str().as_encoded_bytes())
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
if unsafe {
|
||||
libc::renameat2(
|
||||
libc::AT_FDCWD,
|
||||
from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
} < 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
File::open(output_parent)?.sync_all()?;
|
||||
Ok(software)
|
||||
}
|
||||
pub fn load(directory: &Path) -> Result<Software> {
|
||||
let path = directory.join("software.toml");
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(Error("Software descriptor must be a regular file".into()));
|
||||
}
|
||||
let software: Software = toml::from_str(&read_text(&path, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid software descriptor: {e}")))?;
|
||||
software.validate()?;
|
||||
archive::verify(
|
||||
&archive::open(&directory.join(format!("{}.tar.xz", software.id)))?,
|
||||
&software,
|
||||
None,
|
||||
)?;
|
||||
Ok(software)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! A prepared full image and a recorded target identity precede every write.
|
||||
use crate::cartridge::{self, Inspection};
|
||||
use fds_burn::{
|
||||
device::Disk,
|
||||
image::{self, Image},
|
||||
write,
|
||||
};
|
||||
use fds_common::{Error, Result, read_text};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Write,
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FileTarget {
|
||||
path: PathBuf,
|
||||
device: u64,
|
||||
inode: u64,
|
||||
bytes: u64,
|
||||
mtime: i64,
|
||||
mtime_ns: i64,
|
||||
sha256: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Target {
|
||||
Usb { disk: Disk },
|
||||
File { identity: FileTarget },
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Preview {
|
||||
pub format: u32,
|
||||
pub image_path: PathBuf,
|
||||
pub image: Image,
|
||||
pub target: Target,
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
fn file_identity(path: &Path, file: &File) -> Result<FileTarget> {
|
||||
let before = file.metadata()?;
|
||||
if !before.is_file() {
|
||||
return Err(Error("Test target must be an existing regular file".into()));
|
||||
}
|
||||
let sha256 = image::digest(file, before.len(), |_| Ok(()))?;
|
||||
let after = file.metadata()?;
|
||||
if before.mtime() != after.mtime()
|
||||
|| before.mtime_nsec() != after.mtime_nsec()
|
||||
|| before.len() != after.len()
|
||||
{
|
||||
return Err(Error("Test target changed during preview".into()));
|
||||
}
|
||||
Ok(FileTarget {
|
||||
path: path.to_owned(),
|
||||
device: before.dev(),
|
||||
inode: before.ino(),
|
||||
bytes: before.len(),
|
||||
mtime: before.mtime(),
|
||||
mtime_ns: before.mtime_nsec(),
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
fn target(path: &Path, allow_file: bool) -> Result<Target> {
|
||||
let path = path.canonicalize()?;
|
||||
let meta = fs::metadata(&path)?;
|
||||
if allow_file {
|
||||
return Ok(Target::File {
|
||||
identity: file_identity(&path, &cartridge::open_image(&path)?)?,
|
||||
});
|
||||
}
|
||||
if !meta.file_type().is_block_device() {
|
||||
return Err(Error("Target must be a whole USB block device; --file-target is only for disposable test files".into()));
|
||||
}
|
||||
let sysfs = fs::canonicalize(format!(
|
||||
"/sys/dev/block/{}:{}",
|
||||
libc::major(meta.rdev()),
|
||||
libc::minor(meta.rdev())
|
||||
))?;
|
||||
if sysfs.join("partition").exists() {
|
||||
return Err(Error("Select the whole USB drive, not a partition".into()));
|
||||
}
|
||||
let usb = sysfs
|
||||
.ancestors()
|
||||
.find(|p| p.join("idVendor").is_file() && p.join("idProduct").is_file())
|
||||
.ok_or_else(|| Error("Refusing a non-USB disk".into()))?;
|
||||
let disk = Disk::select_current(usb)?;
|
||||
if disk.path != path {
|
||||
return Err(Error(
|
||||
"USB topology does not identify the requested whole disk".into(),
|
||||
));
|
||||
}
|
||||
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
|
||||
Ok(Target::Usb { disk })
|
||||
}
|
||||
impl Target {
|
||||
fn bytes(&self) -> u64 {
|
||||
match self {
|
||||
Self::Usb { disk } => disk.bytes,
|
||||
Self::File { identity } => identity.bytes,
|
||||
}
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
match self {
|
||||
Self::Usb { disk } => &disk.path,
|
||||
Self::File { identity } => &identity.path,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn phrase(image: &Image, target: &Target) -> Result<String> {
|
||||
let hash = image
|
||||
.sha256
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error("Missing prepared-image digest".into()))?;
|
||||
let data = serde_json::to_vec(target).map_err(|e| Error(e.to_string()))?;
|
||||
let binding = image::hex(&Sha256::digest(data));
|
||||
Ok(format!(
|
||||
"WRITE {} {} {}",
|
||||
target.path().display(),
|
||||
hash,
|
||||
&binding[..16]
|
||||
))
|
||||
}
|
||||
pub fn preview(
|
||||
image_path: &Path,
|
||||
target_path: &Path,
|
||||
output: &Path,
|
||||
allow_file: bool,
|
||||
runner: Option<&Path>,
|
||||
) -> Result<Preview> {
|
||||
crate::workstation()?;
|
||||
let image_path = image_path.canonicalize()?;
|
||||
let Inspection { image, .. } = cartridge::inspect(&image_path, runner)?;
|
||||
let target = target(target_path, allow_file)?;
|
||||
if image_path == target.path() {
|
||||
return Err(Error("Image and target must differ".into()));
|
||||
}
|
||||
if target.bytes() < image.bytes || target.bytes() % 512 != 0 {
|
||||
return Err(Error(
|
||||
"Target is smaller than the complete image or not sector aligned".into(),
|
||||
));
|
||||
}
|
||||
let preview = Preview {
|
||||
format: 1,
|
||||
confirmation: phrase(&image, &target)?,
|
||||
image_path,
|
||||
image,
|
||||
target,
|
||||
};
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(output)?;
|
||||
file.write_all(
|
||||
serde_json::to_string_pretty(&preview)
|
||||
.map_err(|e| Error(e.to_string()))?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
file.write_all(b"\n")?;
|
||||
file.sync_all()?;
|
||||
Ok(preview)
|
||||
}
|
||||
pub fn write(preview: &Path, confirmation: &str) -> Result<()> {
|
||||
crate::workstation()?;
|
||||
if !preview.symlink_metadata()?.is_file() {
|
||||
return Err(Error("Write preview must be a regular file".into()));
|
||||
}
|
||||
let approval: Preview = serde_json::from_str(&read_text(preview, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid write preview: {e}")))?;
|
||||
if approval.format != 1
|
||||
|| phrase(&approval.image, &approval.target)? != approval.confirmation
|
||||
|| confirmation != approval.confirmation
|
||||
{
|
||||
return Err(Error(
|
||||
"Confirmation does not match this exact image and target".into(),
|
||||
));
|
||||
}
|
||||
let source = cartridge::open_image(&approval.image_path)?;
|
||||
let target = match &approval.target {
|
||||
Target::Usb { disk } => {
|
||||
if target(&disk.path, false)? != approval.target {
|
||||
return Err(Error(
|
||||
"USB target identity no longer matches the preview".into(),
|
||||
));
|
||||
}
|
||||
disk.open_exclusive()?
|
||||
}
|
||||
Target::File { identity } => {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Disposable file-target tests must run as an ordinary user".into(),
|
||||
));
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
|
||||
.open(&identity.path)?;
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } < 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
if file_identity(&identity.path, &file)? != *identity {
|
||||
return Err(Error(
|
||||
"Test target changed since preview; no bytes written".into(),
|
||||
));
|
||||
}
|
||||
file
|
||||
}
|
||||
};
|
||||
if source.metadata()?.dev() == target.metadata()?.dev()
|
||||
&& source.metadata()?.ino() == target.metadata()?.ino()
|
||||
{
|
||||
return Err(Error("Source and target refer to the same file".into()));
|
||||
}
|
||||
if let Target::Usb { disk } = &approval.target {
|
||||
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
|
||||
}
|
||||
write::transfer(
|
||||
&source,
|
||||
&target,
|
||||
&approval.image,
|
||||
approval.target.bytes(),
|
||||
|phase, bytes| {
|
||||
eprintln!("{phase}: {bytes} bytes");
|
||||
if let Target::Usb { disk } = &approval.target {
|
||||
if !disk.present() {
|
||||
return Err(Error("USB target changed during transfer".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
if let Target::Usb { .. } = approval.target {
|
||||
// Refresh the kernel's view only after verified full-image transfer.
|
||||
if unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) } < 0 {
|
||||
return Err(Error(format!(
|
||||
"Image verified, but partition reread failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
}
|
||||
println!("VERIFIED: complete cartridge image written, flushed and read back");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user