Package workstation tools, reuse emulator sessions, and derive build versions

This commit is contained in:
2026-09-23 03:45:48 +08:00
parent 6bfcce8070
commit f5d9f96409
79 changed files with 878 additions and 132 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "dasungd"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
description = "DASUNG Paperlike USB keepalive, control socket and Linux display profile daemon"
license = "MIT"
+2 -2
View File
@@ -31,7 +31,7 @@ use std::{
};
#[derive(Parser)]
#[command(version, about)]
#[command(version = env!("FDS_BUILD_VERSION"), about)]
struct Cli {
#[arg(long, global = true, default_value = "/etc/dasungd.toml")]
config: PathBuf,
@@ -286,7 +286,7 @@ fn daemon(config: Config) -> Result<()> {
let mut last_error = String::new();
eprintln!(
"dasungd {} ready; waiting for {}",
env!("CARGO_PKG_VERSION"),
env!("FDS_BUILD_VERSION"),
config.monitor_serial
);
while running.load(Ordering::Relaxed) {
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-boottrace"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Measured FDS boot events and regression reports"
+1 -1
View File
@@ -80,7 +80,7 @@ fn adopt() -> Result<()> {
}
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "Record and compare measured boot events",
after_help = "Measurements use Linux CLOCK_BOOTTIME, excluding firmware and power-on."
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-burn"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Verified cartridge images and protected FDS media writes"
+1 -1
View File
@@ -5,7 +5,7 @@ use std::{fs::OpenOptions, os::unix::fs::OpenOptionsExt, path::PathBuf, process:
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "Create cartridge images and preview confirmed media writes",
after_help = "Creation writes a new regular file. Writes require confirmation tied to the selected insertion and image hash."
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-cartridged"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "FDS event-driven cartridge and physical bay manager"
+1 -1
View File
@@ -14,7 +14,7 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "Run the root cartridge service or inspect USB topology"
)]
struct Cli {
+2 -1
View File
@@ -226,7 +226,8 @@ fn session() -> Result<()> {
}
#[derive(clap::Parser)]
#[command(
version,
name = "fds-profile",
version = env!("FDS_BUILD_VERSION"),
about = "Control the desktop and network profile",
after_help = "Examples: fds-profile activate windowmaker; fds-profile deactivate"
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-cli"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "FDS system and cartridge command interface"
+4 -4
View File
@@ -12,10 +12,10 @@ pub struct Cli {
#[derive(Debug, Subcommand)]
pub enum Applet {
#[command(version, about = "FDS/OS system and cartridge control")]
#[command(version = env!("FDS_BUILD_VERSION"), about = "FDS/OS system and cartridge control")]
Fds(FdsArgs),
/// Inspect a bay, image, or cartridge manifest.
#[command(version)]
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsInspect {
#[arg(long, global = true)]
json: bool,
@@ -23,7 +23,7 @@ pub enum Applet {
args: InspectArgs,
},
/// Unmount a cartridge before removal.
#[command(version)]
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsEject {
#[arg(long, global = true)]
json: bool,
@@ -31,7 +31,7 @@ pub enum Applet {
args: BayArgs,
},
/// Prepare native shutdown or inspect its status.
#[command(version)]
#[command(version = env!("FDS_BUILD_VERSION"))]
FdsPower {
#[arg(long, global = true)]
json: bool,
+1 -1
View File
@@ -16,7 +16,7 @@ struct Invocation {
#[derive(Parser)]
#[command(
name = "fds-program",
version,
version = env!("FDS_BUILD_VERSION"),
disable_help_subcommand = true,
about = "Run a published cartridge command in the foreground"
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-common"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Shared bounded configuration and Linux discovery for FDS tools"
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod trace;
use std::{fmt, fs::File, io::Read, path::Path, str::FromStr};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const VERSION: &str = env!("FDS_BUILD_VERSION");
pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;
pub type Result<T> = std::result::Result<T, Error>;
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-control"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Native grayscale X11 cartridge control panel"
+1 -1
View File
@@ -24,7 +24,7 @@ type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "FDS cartridge control panel for X11",
after_help = "Select a bay to inspect its cartridge. Run opens a terminal; Eject stops its programs and releases the cartridge. Remove media only after SAFE appears."
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-release"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "FDS release manifest signing and explicit-key verification"
+7 -1
View File
@@ -63,7 +63,13 @@ pub fn filename(value: &str) -> Result<()> {
impl Manifest {
pub fn validate(&self) -> Result<()> {
if self.format != 1
|| self.version != fds_common::VERSION
|| self.version.is_empty()
|| self.version.len() > 32
|| !self.version.starts_with(|c: char| c.is_ascii_digit())
|| !self
.version
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.')
|| self.source_epoch == 0
|| !hex_valid(&self.source_sha256, 64)
|| !hex_valid(&self.void_commit, 40)
+1 -1
View File
@@ -4,7 +4,7 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "Sign and verify FDS release artifacts",
after_help = "Keys and signatures are never overwritten. Verification checks every listed artifact.
An independently trusted public key is required; a bundled key is not automatically trusted."
+2 -1
View File
@@ -1,5 +1,6 @@
[package]
name = "fds-smoketest"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2021"
publish = false
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-software"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Verified installed software trees and legacy archive reading"
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-stage0"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Static FDS early boot discovery and root handoff"
+8 -2
View File
@@ -364,7 +364,10 @@ pub fn boot() -> Result<()> {
let events = netlink()?; // Bind before scanning so insertion cannot fall into a gap.
let signals = child_events()?;
let mut monitor = start_monitor(options.debug)?;
println!("FELIS DATA SYSTEMS\nPORTABLE COMPUTER FP-85\n\nFDS BOOT ROM 0.1");
println!(
"FELIS DATA SYSTEMS\nPORTABLE COMPUTER FP-85\n\nFDS BOOT ROM {}",
fds_common::VERSION
);
println!("Commands while waiting: list, rescan, recovery, reboot, poweroff");
let mut previous = String::new();
let mut pending = ConsoleInput::default();
@@ -397,7 +400,10 @@ pub fn boot() -> Result<()> {
if options.debug {
println!("FDS_STAGE0_ROOT: {}", options.root_label());
}
println!("MEMORY ........ READY\nSYSTEM ........ FDS/OS 0.1");
println!(
"MEMORY ........ READY\nSYSTEM ........ FDS/OS {}",
fds_common::VERSION
);
return handoff(monitor, options.debug);
}
Err(error) => {
+1 -1
View File
@@ -5,7 +5,7 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
version = env!("FDS_BUILD_VERSION"),
about = "Early boot discovery and native s6 handoff",
after_help = "With no arguments, root PID 1 boots FDS. Diagnostic options only read their supplied inputs."
)]
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-workstation"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Linux workstation software and cartridge tools"
+3 -2
View File
@@ -4,12 +4,13 @@ use fds_workstation::emulator::Session;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
name = "fds-emulator",
version = env!("FDS_BUILD_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.
/// Private session directory. A stopped session can be started again.
#[arg(long, global = true, default_value = "out/emulator")]
session: PathBuf,
#[command(subcommand)]
+120 -17
View File
@@ -12,8 +12,11 @@ use std::{
fs::{self, File, OpenOptions},
io::Write,
os::{
fd::AsRawFd,
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{
fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
net::UnixStream,
},
},
path::{Path, PathBuf},
process::Stdio,
@@ -90,6 +93,43 @@ fn lock(root: &Path) -> Result<File> {
}
Ok(file)
}
// A pidfd remains attached to the original process even if its PID is reused.
// QMP EOF alone does not establish that QEMU finished releasing its files.
fn process(root: &Path) -> Result<Option<OwnedFd>> {
let text = match fs::read_to_string(root.join("qemu.pid")) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let pid: i32 = text
.trim()
.parse()
.map_err(|_| Error("Invalid QEMU pid file".into()))?;
if pid <= 0 {
return Err(Error("Invalid QEMU pid file".into()));
}
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as i32;
if fd < 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
return Ok(None);
}
return Err(error.into());
}
Ok(Some(unsafe { OwnedFd::from_raw_fd(fd) }))
}
fn exited(process: &OwnedFd, timeout_ms: i32) -> Result<bool> {
let mut event = libc::pollfd {
fd: process.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let result = unsafe { libc::poll(&mut event, 1, timeout_ms) };
if result < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(result > 0 && event.revents & libc::POLLIN != 0)
}
impl Session {
pub fn load(root: &Path) -> Result<Self> {
ordinary()?;
@@ -159,17 +199,70 @@ impl Session {
"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 runner = runner.map(regular).transpose()?;
let guard = match fs::DirBuilder::new().mode(0o700).create(&root) {
Ok(()) => lock(&root)?,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let previous = Self::load(&root)?;
if process(&root)?
.as_ref()
.map(|pid| exited(pid, 0))
.transpose()?
== Some(false)
{
return Err(Error(
"Session still has a live process; stop it before starting again".into(),
));
}
for name in ["qmp.sock", "console.sock"] {
match UnixStream::connect(root.join(name)) {
Ok(_) => {
return Err(Error(
"Session still has an active socket; stop it before starting again"
.into(),
));
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound
| std::io::ErrorKind::ConnectionRefused
) =>
{
()
}
Err(error) => return Err(error.into()),
}
}
// Keep the previous cartridge-to-overlay mapping as well as the
// overlay files. A fresh boot starts with empty cartridge bays.
let archive = root.join(format!(
"previous-{}.json",
image::hex(&image::random_id()?)
));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(archive)?;
file.write_all(
&serde_json::to_vec_pretty(&previous.state)
.map_err(|e| Error(e.to_string()))?,
)?;
file.sync_all()?;
for name in ["qmp.sock", "console.sock", "qemu.pid"] {
match fs::remove_file(root.join(name)) {
Ok(()) => (),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
Err(error) => return Err(error.into()),
}
}
previous._lock
}
Err(error) => return Err(error.into()),
};
let session = Self {
_lock: lock(&root)?,
_lock: guard,
root,
state: State {
format: 1,
@@ -177,7 +270,7 @@ impl Session {
kernel,
initramfs,
system,
qemu_runner: runner.map(regular).transpose()?,
qemu_runner: runner,
cartridges: BTreeMap::new(),
},
};
@@ -193,7 +286,7 @@ impl Session {
.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"))?);
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(OpenOptions::new().create(true).append(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(session.root.join("qemu.log"))?);
crate::success(&mut command)
.map_err(|e| Error(format!("{e}; inspect {}/qemu.log", session.root.display())))?;
let boot = (|| -> Result<()> {
@@ -344,16 +437,26 @@ impl Session {
}
pub fn stop(&self, force: bool) -> Result<Value> {
let mut qmp = self.qmp()?;
if force {
qmp.execute("quit", json!({}))?;
let process = process(&self.root)?.ok_or_else(|| {
Error("QEMU process record is unavailable; cannot confirm shutdown".into())
})?;
let response = if force {
// QEMU may reset QMP before acknowledging quit. The pidfd,
// rather than transport EOF or a reply, establishes actual exit.
qmp.execute("quit", json!({})).map(|_| ())
} 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()
};
if !exited(&process, 30_000)? {
return Err(response
.err()
.unwrap_or_else(|| Error("QEMU has not exited; session remains in use".into())));
}
qmp.closed()?;
Ok(json!({"stopped":self.root,"forced":force,"logs_and_data_overlays_retained":true}))
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
name = "fds-cartridge",
version = env!("FDS_BUILD_VERSION"),
about = "Build Void source packages and ready-to-run cartridge images on Linux"
)]
struct Cli {