FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+25
View File
@@ -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"
+283
View File
@@ -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,
})
}
+57
View File
@@ -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"}),
)
}
+135
View File
@@ -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());
}
}
+359
View File
@@ -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}))
}
}
+101
View File
@@ -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)
}
+137
View File
@@ -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());
}
}
+127
View File
@@ -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)?,
}
}
}
}
+219
View File
@@ -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)'");
}
}
+152
View File
@@ -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)
}
+253
View File
@@ -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(())
}