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
View File
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "fds-cli"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "FDS system and cartridge command interface"
[[bin]]
name = "fds"
path = "src/main.rs"
[dependencies]
clap.workspace = true
fds-burn = { path = "../fds-burn" }
fds-common = { path = "../fds-common" }
serde_json = "1"
libc = "0.2"
+409
View File
@@ -0,0 +1,409 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use fds_burn::cli::{BurnCommand, FormatCommand};
use fds_common::Bay;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(multicall = true)]
pub struct Cli {
#[command(subcommand)]
pub applet: Applet,
}
#[derive(Debug, Subcommand)]
pub enum Applet {
#[command(version, about = "FDS/OS system and cartridge control")]
Fds(FdsArgs),
/// Inspect a bay, image, or cartridge manifest.
#[command(version)]
FdsInspect {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: InspectArgs,
},
/// Unmount a cartridge before removal.
#[command(version)]
FdsEject {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: BayArgs,
},
/// Prepare native shutdown or inspect its status.
#[command(version)]
FdsPower {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: PowerArgs,
},
}
impl Cli {
pub fn into_command(self) -> (Option<Action>, bool) {
match self.applet {
Applet::Fds(args) => (args.command, args.json),
Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json),
Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json),
Applet::FdsPower { json, args } => (Some(Action::Power(args)), json),
}
}
}
#[derive(Debug, Args)]
pub struct FdsArgs {
/// Emit machine-readable JSON where supported.
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Option<Action>,
}
#[derive(Debug, Subcommand)]
pub enum Action {
/// Show the system and tool identity.
Info,
/// Show all twelve bays.
Bays,
/// Show one bay and its cartridge state.
#[command(visible_alias = "cartridge")]
Bay(BayArgs),
/// Unmount a cartridge before removal.
Eject(BayArgs),
/// Select writable DATA.
Data {
#[command(subcommand)]
command: DataCommand,
},
/// Start a managed DATA job or PROGRAM executable.
Run {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
/// Executable and its arguments; separate them from FDS options with --.
#[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")]
arguments: Vec<String>,
},
/// Show desktop and network state.
Profiles,
/// Activate a desktop or return to the console.
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
/// Enable or stop DHCP networking.
Network {
#[arg(value_enum)]
state: Switch,
},
/// Refresh cartridge inventory.
Rescan,
/// Show USB paths for bay calibration.
Topology,
/// Inspect a bay, image, or manifest file.
Inspect(InspectArgs),
/// Preview and confirm a cartridge write, or manage an existing operation.
Burn {
#[command(subcommand)]
command: BurnCommand,
},
/// Create a cartridge and preview a confirmed write.
Format {
#[command(subcommand)]
command: FormatCommand,
},
/// Manage persistent machine settings and saved diagnostics.
Machine {
#[command(subcommand)]
command: Option<MachineCommand>,
},
/// Check DATA or confirm conservative repair from the root recovery console.
Recovery {
#[command(subcommand)]
command: Option<RecoveryCommand>,
},
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
/// Show, resume, or request native shutdown preparation.
Power(PowerArgs),
/// Show measured boot events.
BootProfile,
/// Show the tool version.
Version,
}
#[derive(Debug, Args)]
pub struct BayArgs {
#[arg(value_parser = fds_burn::client::bay)]
pub bay: Bay,
}
#[derive(Debug, Subcommand)]
pub enum DataCommand {
/// Select a DATA cartridge for /data.
Use(BayArgs),
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
/// Activate a profile.
Activate {
#[arg(value_parser = ["windowmaker", "cli"])]
name: String,
},
/// Return to the console.
Deactivate,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum Switch {
On,
Off,
}
#[derive(Debug)]
pub struct InspectArgs {
pub target: Option<PathBuf>,
pub command: Option<InspectCommand>,
}
#[derive(Debug, Args)]
#[command(subcommand_negates_reqs = true)]
struct InspectParser {
/// A bay number (1–12 or BAY1–BAY12), or a manifest file path.
#[arg(required = true, value_name = "BAY_OR_MANIFEST")]
target: Option<PathBuf>,
#[command(subcommand)]
command: Option<InspectCommand>,
}
impl TryFrom<InspectParser> for InspectArgs {
type Error = clap::Error;
fn try_from(parsed: InspectParser) -> Result<Self, Self::Error> {
// Clap's args_conflicts_with_subcommands also conflicts with global
// --json. Check only these two typed alternatives during conversion.
if parsed.target.is_some() && parsed.command.is_some() {
return Err(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
"Choose a bay/manifest target or the image subcommand, not both",
));
}
Ok(Self {
target: parsed.target,
command: parsed.command,
})
}
}
impl clap::FromArgMatches for InspectArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
InspectParser::from_arg_matches(matches)?.try_into()
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
let mut parsed = InspectParser {
target: self.target.clone(),
command: self.command.clone(),
};
parsed.update_from_arg_matches(matches)?;
*self = parsed.try_into()?;
Ok(())
}
}
impl Args for InspectArgs {
fn augment_args(command: clap::Command) -> clap::Command {
InspectParser::augment_args(command)
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
InspectParser::augment_args_for_update(command)
}
}
#[derive(Clone, Debug, Subcommand)]
pub enum InspectCommand {
/// Validate a regular cartridge image and calculate its SHA-256 digest.
Image { image: PathBuf },
}
#[derive(Debug, Args)]
pub struct PowerArgs {
#[command(subcommand)]
pub command: Option<PowerCommand>,
}
#[derive(Debug, Subcommand)]
pub enum PowerCommand {
/// Show preparation status or its blocking error (the default).
Status,
/// Resume operations before native shutdown starts.
Resume,
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
#[command(long_flag = "shutdown-hook", hide = true)]
ShutdownHook,
#[command(long_flag = "hold-shutdown", hide = true)]
HoldShutdown,
#[command(long_flag = "record-final", hide = true)]
RecordFinal {
#[arg(value_parser = ["failed"])]
outcome: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum RecoveryCommand {
/// Unmount and check DATA without repairs.
Check(BayArgs),
/// Preview repair, or confirm the exact token returned by the preview.
Repair {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
#[arg(long, value_name = "TOKEN")]
confirm: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum MachineCommand {
/// Show the active machine identity and settings source.
Status,
/// Export this boot's active settings into a new directory.
Export { new_directory: PathBuf },
/// Validate machine.toml, bays.toml and hardware-catalog.toml.
Validate { directory: PathBuf },
/// Validate and pack settings into a new JSON file.
Pack {
directory: PathBuf,
new_json_file: PathBuf,
},
/// Install settings for the next boot (root recovery console only).
Install { directory: PathBuf },
/// Save an explicit diagnostic of up to 16 MiB (root only).
Store { name: String, file: PathBuf },
/// Retrieve a saved diagnostic into a new file (root only).
Fetch { name: String, new_file: PathBuf },
#[command(long_flag = "load", hide = true)]
Load,
}
pub fn help(subcommand: Option<&str>) -> std::io::Result<()> {
let mut root = Cli::command();
root.build();
let fds = root.find_subcommand_mut("fds").expect("fds applet");
let command = match subcommand {
Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"),
None => fds,
};
command.print_help()?;
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_tree_and_installed_aliases() {
Cli::command().debug_assert();
for arguments in [
vec!["fds", "--json", "inspect", "BAY12"],
vec!["/usr/bin/fds-inspect", "BAY12", "--json"],
vec!["fds-inspect", "image", "card.img"],
vec!["fds-eject", "--json", "12"],
vec!["fds-power", "status", "--json"],
vec!["fds", "cartridge", "12"],
vec![
"fds",
"recovery",
"repair",
"BAY1",
"--confirm",
"exact phrase",
],
vec!["fds", "machine", "pack", "settings", "new.json"],
vec!["fds", "machine", "--load"],
vec!["fds", "power", "--shutdown-hook"],
vec!["fds-power", "--hold-shutdown"],
vec!["fds-power", "--record-final", "failed"],
] {
assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}");
}
for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] {
let error = Cli::try_parse_from([name, "--help"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
assert!(error.to_string().contains(&format!("Usage: {name}")));
}
}
#[test]
fn run_preserves_every_child_argument_after_separator() {
let (command, json) = Cli::try_parse_from([
"fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b",
])
.unwrap()
.into_command();
assert!(!json);
let Some(Action::Run { bay, arguments }) = command else {
panic!("run command")
};
assert_eq!(bay, "2".parse::<Bay>().unwrap());
assert_eq!(
arguments,
["/bin/app", "--json", "--help", "-x", "", "--", "a b"]
);
let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"])
.unwrap()
.into_command();
assert!(json);
}
#[test]
fn inspect_image_accepts_global_options_in_every_position() {
for arguments in [
vec!["fds", "--json", "inspect", "image", "card.img"],
vec!["fds", "inspect", "--json", "image", "card.img"],
vec!["fds", "inspect", "image", "card.img", "--json"],
vec!["fds-inspect", "--json", "image", "card.img"],
vec!["fds-inspect", "image", "--json", "card.img"],
] {
let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command();
assert!(json);
assert!(matches!(
command,
Some(Action::Inspect(InspectArgs {
command: Some(InspectCommand::Image { .. }),
target: None,
}))
));
}
}
#[test]
fn rejects_incomplete_or_conflicting_requests_before_execution() {
for arguments in [
vec!["fds", "eject", "0"],
vec!["fds-eject", "13"],
vec!["fds", "run", "1", "/bin/app"],
vec!["fds", "run", "1", "--"],
vec!["fds", "inspect", "image"],
vec!["fds-inspect", "1", "image", "card.img"],
vec!["fds", "recovery", "check", "1", "--confirm", "token"],
vec!["fds", "recovery", "repair", "1", "--confirm"],
vec![
"fds",
"recovery",
"repair",
"1",
"--confirm",
"one",
"--confirm",
"two",
],
vec!["fds", "power", "--shutdown-hook", "--record-final"],
vec!["fds", "power", "--record-final", "success"],
vec!["fds", "machine", "--load", "settings"],
vec!["fds", "network", "maybe"],
vec!["fds", "profile", "activate", "unknown"],
vec!["fds", "unknown"],
] {
assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}");
}
}
}
+510
View File
@@ -0,0 +1,510 @@
//! Internal NVMe access is explicit and isolated in this process's mount namespace.
use fds_common::{
Error, Result,
machine::{self, Config},
read_text, sysfs,
};
use std::{
ffi::CString,
fs::{self, File, OpenOptions},
io::{self, Read, Write},
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
},
path::{Path, PathBuf},
};
const RUNTIME: &str = "/run/fds/machine";
const MOUNT: &str = "/run/fds/machine/internal";
fn c(text: &str) -> Result<CString> {
CString::new(text).map_err(|_| Error("NUL in mount argument".into()))
}
fn checked(value: libc::c_int, what: &str) -> Result<()> {
if value < 0 {
Err(Error(format!("{what}: {}", io::Error::last_os_error())))
} else {
Ok(())
}
}
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("Machine storage operations require root".into()));
}
Ok(())
}
fn prepare() -> Result<File> {
root()?;
fs::create_dir_all(RUNTIME)?;
fs::set_permissions(RUNTIME, fs::Permissions::from_mode(0o755))?;
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(format!("{RUNTIME}/lock"))?;
checked(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
"Another machine storage operation is active",
)?;
Ok(lock)
}
fn parent(part: &sysfs::BlockPartition) -> Result<PathBuf> {
let path = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?;
path.parent()
.map(Path::to_path_buf)
.ok_or_else(|| Error("Partition has no parent disk".into()))
}
fn select() -> Result<(sysfs::BlockPartition, PathBuf, String)> {
let parts = sysfs::partitions(Path::new("/sys"))?;
let mut candidates = Vec::new();
for part in parts.iter().filter(|p| p.partition_name == "FDS_INTERNAL") {
let disk = parent(part)?;
let buses: Vec<_> = disk
.ancestors()
.filter_map(|path| fs::read_link(path.join("subsystem")).ok())
.filter_map(|path| path.file_name().map(|s| s.to_owned()))
.collect();
if !buses.iter().any(|b| b == "nvme") || buses.iter().any(|b| b == "usb") {
continue;
}
if read_text(&disk.join("removable"), 32)?.trim() != "0" {
continue;
}
let siblings: Vec<_> = parts
.iter()
.filter(|p| parent(p).ok().as_ref() == Some(&disk))
.collect();
if siblings.len() != 3 {
return Err(Error(
"Internal NVMe must contain exactly FDS_BOOT, FDS_RECOVERY and FDS_INTERNAL".into(),
));
}
for (number, label) in [(1, "FDS_BOOT"), (2, "FDS_RECOVERY"), (3, "FDS_INTERNAL")] {
let matches: Vec<_> = siblings
.iter()
.filter(|p| p.partition_name == label)
.collect();
if matches.len() != 1
|| read_text(
&PathBuf::from(format!(
"/sys/dev/block/{}:{}/partition",
matches[0].major, matches[0].minor
)),
32,
)?
.trim()
!= number.to_string()
{
return Err(Error("Invalid internal NVMe partition layout".into()));
}
}
let sequence = read_text(&disk.join("diskseq"), 32)?.trim().to_owned();
sequence
.parse::<u64>()
.map_err(|_| Error("Invalid internal disk sequence".into()))?;
candidates.push((part.clone(), disk, sequence));
}
match candidates.len() {
0 => Err(Error(
"No complete internal NVMe layout found; image defaults remain available".into(),
)),
1 => Ok(candidates.pop().unwrap()),
_ => Err(Error(
"Multiple internal NVMe layouts found; refusing to choose one".into(),
)),
}
}
struct Internal {
file: File,
disk: PathBuf,
sequence: String,
mounted: bool,
}
impl Internal {
fn open(writable: bool) -> Result<Self> {
let (part, disk, sequence) = select()?;
let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
if mounts.lines().any(|line| {
line.split_whitespace().nth(2) == Some(&format!("{}:{}", part.major, part.minor))
}) {
return Err(Error(
"Internal settings are already mounted; unmount them before continuing".into(),
));
}
let file = OpenOptions::new()
.read(true)
.write(writable)
.custom_flags(libc::O_NOFOLLOW)
.open(&part.device)?;
let meta = file.metadata()?;
if !meta.file_type().is_block_device()
|| libc::major(meta.rdev()) != part.major
|| libc::minor(meta.rdev()) != part.minor
{
return Err(Error("Internal partition identity changed".into()));
}
let mut header = [0u8; 1024];
file.read_exact_at(&mut header, 1024)?;
let word = |at| u16::from_le_bytes([header[at], header[at + 1]]);
let features = u32::from_le_bytes(header[96..100].try_into().unwrap());
if word(56) != 0xef53
|| word(58) != 1
|| features & 4 != 0
|| &header[120..136] != b"FDS_INTERNAL\0\0\0\0"
{
return Err(Error("Internal ext4 is unclean, damaged or incorrectly labeled; offline filesystem maintenance is required".into()));
}
checked(
unsafe { libc::unshare(libc::CLONE_NEWNS) },
"Isolate internal storage mounts",
)?;
checked(
unsafe {
libc::mount(
std::ptr::null(),
c("/")?.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
},
"Make storage mount private",
)?;
fs::create_dir_all(MOUNT)?;
fs::set_permissions(MOUNT, fs::Permissions::from_mode(0o700))?;
let mut internal = Self {
file,
disk,
sequence,
mounted: false,
};
internal.identity()?;
let options = c(if writable {
"errors=remount-ro"
} else {
"noload"
})?;
checked(
unsafe {
libc::mount(
c(&format!("/proc/self/fd/{}", internal.file.as_raw_fd()))?.as_ptr(),
c(MOUNT)?.as_ptr(),
c("ext4")?.as_ptr(),
libc::MS_NOSUID
| libc::MS_NODEV
| libc::MS_NOEXEC
| if writable { 0 } else { libc::MS_RDONLY },
options.as_ptr().cast(),
)
},
"Mount internal settings",
)?;
internal.mounted = true;
internal.identity()?;
Ok(internal)
}
fn identity(&self) -> Result<()> {
if read_text(&self.disk.join("diskseq"), 32)?.trim() != self.sequence {
return Err(Error("Internal NVMe changed during the operation".into()));
}
Ok(())
}
fn close(mut self, writable: bool) -> Result<()> {
self.identity()?;
if writable {
let directory = File::open(MOUNT)?;
checked(
unsafe { libc::syncfs(directory.as_raw_fd()) },
"Flush internal settings filesystem",
)?;
}
checked(
unsafe { libc::umount(c(MOUNT)?.as_ptr()) },
"Unmount internal settings",
)?;
self.mounted = false;
if writable {
self.file.sync_all()?;
}
Ok(())
}
}
impl Drop for Internal {
fn drop(&mut self) {
if self.mounted {
// No lazy unmount. The private namespace is also destroyed on exit.
if let Ok(path) = c(MOUNT) {
unsafe {
libc::umount(path.as_ptr());
}
}
}
}
}
fn atomic(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error("Missing parent directory".into()))?;
let temporary = path.with_extension(format!("next-{}", std::process::id()));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.custom_flags(libc::O_NOFOLLOW)
.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temporary, path)?;
File::open(parent)?.sync_all()?;
Ok(())
}
fn emulator_config() -> Result<Option<Config>> {
if !read_text(Path::new("/proc/cmdline"), 65536)?
.split_whitespace()
.any(|s| s == "fds.emulator=1")
{
return Ok(None);
}
let compatible = fs::read("/proc/device-tree/compatible")?;
if !compatible
.split(|b| *b == 0)
.any(|s| s == b"linux,dummy-virt")
{
return Err(Error(
"fds.emulator=1 requires the QEMU virt machine".into(),
));
}
let controller = fs::canonicalize("/sys/bus/pci/devices/0000:00:05.0")?;
if read_text(&controller.join("vendor"), 64)?.trim() != "0x1b36"
|| read_text(&controller.join("device"), 64)?.trim() != "0x000d"
{
return Err(Error(
"Emulator requires QEMU xHCI at PCI address 00:05.0".into(),
));
}
let identity = controller
.strip_prefix("/sys/devices")
.map_err(|_| Error("Unexpected emulator controller path".into()))?
.to_string_lossy();
let mut config = Config::fallback()?;
config.name = "FDS QEMU workstation emulator".into();
config.bays.clear();
for protocol in ["usb2", "usb3"] {
config.bays.push_str(&format!(
"[{protocol}]\nhub = \"{identity}:{protocol}\"\n[{protocol}.ports]\n"
));
for n in 1..=12 {
config.bays.push_str(&format!("{n} = {n}\n"));
}
}
config.validate()?;
Ok(Some(config))
}
pub fn load() -> Result<()> {
let _lock = prepare()?;
if Path::new(machine::SNAPSHOT).exists() && Path::new(machine::STATUS).exists() {
return Ok(());
}
let attempt = || -> Result<(Config, String)> {
let internal = Internal::open(false)?;
let config = Config::parse(&machine::trusted_text(
Path::new(&format!("{MOUNT}/config/machine.json")),
machine::MAX_BUNDLE,
)?)?;
let sequence = internal.sequence.clone();
internal.close(false)?;
Ok((config, sequence))
};
let emulated = emulator_config()?;
let (config, status) = if let Some(config) = emulated {
let status = serde_json::json!({"source":"qemu_emulator", "name":config.name, "disk_sequence":null, "error":null});
(config, status)
} else {
match attempt() {
Ok((config, sequence)) => {
let status = serde_json::json!({"source":"internal_nvme", "name":config.name, "disk_sequence":sequence, "error":null});
(config, status)
}
Err(error) => {
eprintln!("FDS machine settings: {error}");
let config = Config::fallback()?;
let status = serde_json::json!({"source":"image_defaults", "name":config.name, "disk_sequence":null, "error":error.to_string()});
(config, status)
}
}
};
atomic(
Path::new(machine::STATUS),
&serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?,
0o644,
)?;
atomic(Path::new(machine::SNAPSHOT), &config.json()?, 0o644)?;
Ok(())
}
fn install(directory: &Path) -> Result<()> {
root()?;
if !fds_common::recovery_mode()? {
return Err(Error("Installing machine settings requires the recovery console; changes take effect at the next boot".into()));
}
let config = Config::directory(directory)?;
let _lock = prepare()?;
let internal = Internal::open(true)?;
let current = PathBuf::from(format!("{MOUNT}/config/machine.json"));
// Validate ownership and preserve the previous bytes, even when its JSON is
// damaged: recovery must be able to replace invalid settings with valid ones.
let previous = machine::trusted_text(&current, machine::MAX_BUNDLE)?;
atomic(
&current.with_file_name("previous.json"),
previous.as_bytes(),
0o600,
)?;
atomic(&current, &config.json()?, 0o600)?;
internal.close(true)?;
println!(
"Saved machine settings for {}. Reboot to activate them; the current bay mapping is unchanged.",
config.name
);
Ok(())
}
fn diagnostic_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 80
|| name.starts_with('.')
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
{
return Err(Error("Diagnostic name must be 1..80 letters, digits, dots, hyphens or underscores and must not start with a dot".into()));
}
Ok(())
}
fn store(name: &str, input: &Path) -> Result<()> {
root()?;
diagnostic_name(name)?;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(input)?;
if !file.metadata()?.is_file() {
return Err(Error("Diagnostic input must be a regular file".into()));
}
let mut bytes = Vec::new();
file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(Error("Diagnostic input exceeds 16 MiB".into()));
}
let _lock = prepare()?;
let internal = Internal::open(true)?;
let directory = PathBuf::from(format!("{MOUNT}/diagnostics"));
let meta = fs::symlink_metadata(&directory)?;
if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o077 != 0 {
return Err(Error("Untrusted diagnostics directory".into()));
}
let output = directory.join(name);
if output.symlink_metadata().is_ok() {
return Err(Error("Diagnostic already exists; choose a new name".into()));
}
atomic(&output, &bytes, 0o600)?;
internal.close(true)?;
println!("Saved diagnostics/{name} on internal NVMe; storage is flushed and unmounted.");
Ok(())
}
pub fn run(command: crate::cli::MachineCommand, json: bool) -> Result<()> {
use crate::cli::MachineCommand;
match command {
MachineCommand::Status => {
let text = read_text(Path::new(machine::STATUS), 16384)?;
if json {
println!("{text}");
} else {
let status: serde_json::Value =
serde_json::from_str(&text).map_err(|e| Error(e.to_string()))?;
println!(
"MACHINE {}\nSETTINGS {}",
status["name"].as_str().unwrap_or("unknown"),
status["source"].as_str().unwrap_or("unknown")
);
if let Some(error) = status["error"].as_str() {
println!("DETAIL {error}");
}
}
}
MachineCommand::Validate { directory } => {
let config = Config::directory(&directory)?;
println!("Valid machine settings: {}", config.name);
}
MachineCommand::Pack {
directory,
new_json_file: output,
} => {
let bytes = Config::directory(&directory)?.json()?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&output)?;
file.write_all(&bytes)?;
file.sync_all()?;
println!("Validated machine settings: {}", output.display());
}
MachineCommand::Load => load()?,
MachineCommand::Install { directory } => install(&directory)?,
MachineCommand::Store { name, file: input } => store(&name, &input)?,
MachineCommand::Export {
new_directory: directory,
} => {
let config = Config::active()?;
let path = &directory;
fs::create_dir(path)?;
fs::write(
path.join("machine.toml"),
format!(
"format = 1\nname = {}\n",
serde_json::to_string(&config.name).map_err(|e| Error(e.to_string()))?
),
)?;
fs::write(path.join("bays.toml"), config.bays)?;
fs::write(path.join("hardware-catalog.toml"), config.hardware_catalog)?;
println!(
"Exported this boot's machine settings to {}",
directory.display()
);
}
MachineCommand::Fetch {
name,
new_file: output,
} => {
diagnostic_name(&name)?;
let _lock = prepare()?;
let internal = Internal::open(false)?;
let path = format!("{MOUNT}/diagnostics/{name}");
let input = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let meta = input.metadata()?;
if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error("Untrusted diagnostic file".into()));
}
let mut bytes = Vec::new();
input.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(Error("Diagnostic exceeds 16 MiB".into()));
}
internal.close(false)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(output)?;
output.write_all(&bytes)?;
output.sync_all()?;
println!("Retrieved diagnostics/{name}");
}
}
Ok(())
}
+271
View File
@@ -0,0 +1,271 @@
mod cli;
mod machine;
mod power;
mod recovery;
use clap::Parser;
use cli::{
Action, DataCommand, InspectCommand, PowerCommand, ProfileCommand, RecoveryCommand, Switch,
};
use fds_common::{
Error, Result, VERSION,
control::{self, Request},
manifest::Manifest,
read_text, trace,
};
use std::{path::Path, process::ExitCode};
fn run() -> Result<()> {
let (command, json) = cli::Cli::parse().into_command();
let Some(command) = command else {
return Ok(cli::help(None)?);
};
match command {
Action::Machine {
command: Some(command),
} => machine::run(command, json)?,
Action::Machine { command: None } => cli::help(Some("machine"))?,
Action::Recovery { command: None } => cli::help(Some("recovery"))?,
Action::Recovery {
command: Some(RecoveryCommand::Check(args)),
} => recovery::run(args.bay, false, None, json)?,
Action::Recovery {
command: Some(RecoveryCommand::Repair { bay, confirm }),
} => recovery::run(bay, true, confirm, json)?,
Action::Poweroff => power::client(Request::Poweroff, json)?,
Action::Reboot => power::client(Request::Reboot, json)?,
Action::Power(args) => match args.command.unwrap_or(PowerCommand::Status) {
PowerCommand::Poweroff => power::client(Request::Poweroff, json)?,
PowerCommand::Reboot => power::client(Request::Reboot, json)?,
PowerCommand::Status => power::client(Request::PowerStatus, json)?,
PowerCommand::Resume => power::client(Request::PowerResume, json)?,
PowerCommand::ShutdownHook => power::shutdown_hook()?,
PowerCommand::HoldShutdown => power::hold_shutdown()?,
PowerCommand::RecordFinal { outcome } => power::record_final(outcome.is_some())?,
},
Action::Burn { command } => fds_burn::client::burn(command, json)?,
Action::Format { command } => fds_burn::client::format(command, json)?,
Action::Inspect(args) => {
if let Some(InspectCommand::Image { image: path }) = args.command {
let file = std::fs::File::open(path)?;
if !file.metadata()?.is_file() {
return Err(Error("Image inspection requires a regular file".into()));
}
let mut info = fds_burn::image::inspect(&file, file.metadata()?.len())?;
info.sha256 = Some(fds_burn::image::digest(&file, info.bytes, |_| Ok(()))?);
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
} else {
let path = args
.target
.expect("Clap requires a target or image subcommand");
if let Some(bay) = path
.to_str()
.and_then(|value| fds_burn::client::bay(value).ok())
{
fds_burn::client::inspect_bay(bay, json)?;
} else {
inspect_manifest(&path, json)?;
}
}
}
Action::Profiles => cartridge(Request::Profiles, json)?,
Action::Profile { command } => cartridge(
Request::Profile {
profile: match command {
ProfileCommand::Activate { name } => name,
ProfileCommand::Deactivate => "cli".into(),
},
},
json,
)?,
Action::Network { state } => cartridge(
Request::Network {
enabled: matches!(state, Switch::On),
},
json,
)?,
Action::Bays => cartridge(Request::Bays, json)?,
Action::Bay(args) => cartridge(Request::Bay { bay: args.bay }, json)?,
Action::Eject(args) => cartridge(Request::Eject { bay: args.bay }, json)?,
Action::Rescan => cartridge(Request::Rescan, json)?,
Action::Data {
command: DataCommand::Use(args),
} => cartridge(Request::DataUse { bay: args.bay }, json)?,
Action::Run { bay, arguments } => cartridge(Request::Run { bay, arguments }, json)?,
Action::Topology => cartridge(Request::Topology, json)?,
Action::Version => println!("fds {VERSION}"),
Action::BootProfile => trace::load(Path::new(trace::RUNTIME))?.print(json)?,
Action::Info => info(json)?,
}
Ok(())
}
fn info(json: bool) -> Result<()> {
let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)?
.trim()
.to_owned();
let pid1 = std::fs::read_link("/proc/1/exe")
.map(|p| p.display().to_string())
.unwrap_or_else(|_| {
read_text(Path::new("/proc/1/comm"), 4096)
.map(|s| s.trim().to_owned())
.unwrap_or_else(|_| "unavailable".into())
});
let target = if cfg!(all(
target_arch = "aarch64",
target_env = "musl",
target_feature = "crt-static"
)) {
"aarch64 static-musl"
} else {
"host test build"
};
if json {
println!(
"{}",
serde_json::json!({"fds_version": VERSION, "target": target, "kernel": kernel, "pid1": pid1})
);
} else {
println!("FDS/OS {VERSION}\nTOOLS {target}\nKERNEL {kernel}\nINIT {pid1}");
}
Ok(())
}
fn inspect_manifest(path: &Path, json: bool) -> Result<()> {
let manifest = Manifest::load(path)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&manifest).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"{}\n{} {}\nID {}\nMEDIA {}",
manifest.cartridge.class.label(),
manifest.cartridge.name,
manifest.cartridge.version,
manifest.cartridge.id,
if manifest.media.writable {
"WRITABLE"
} else {
"READ-ONLY"
}
);
if let Some(activation) = manifest.activation {
println!("PROFILE {}", activation.profile);
}
}
Ok(())
}
fn cartridge(request: Request, json: bool) -> Result<()> {
let debug = matches!(request, Request::Topology);
let response = control::request(&request)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&response).map_err(|e| Error(e.to_string()))?
);
} else {
if let Some(pid) = response.started_pid {
println!("STARTED {pid} Output: /run/log/cartridged/current");
}
if let Some(p) = response.profiles {
println!(
"PROFILES cli, windowmaker\nDESKTOP {} {}\nNETWORK {}",
p.desktop,
if p.desktop == "windowmaker" {
if p.ready_ns.is_some() {
"READY"
} else {
"STARTING"
}
} else {
""
},
if p.network.is_empty() {
"OFF".into()
} else {
p.network.join(", ")
}
);
if let Some(bay) = p.environment_bay {
println!("ENVIRONMENT BAY {bay}");
}
if let (Some(start), Some(end)) = (p.activation_ns, p.ready_ns) {
println!(
"ACTIVATION TO READY {:.3} ms",
end.saturating_sub(start) as f64 / 1_000_000.0
);
}
if let Some(error) = p.error {
println!("ERROR {error}");
}
return Ok(());
}
for bay in response.bays {
println!(
"BAY {} {}{}",
bay.bay,
bay.state.to_uppercase().replace('_', " "),
bay.name
.as_ref()
.map(|n| format!(" {n}"))
.unwrap_or_default()
);
if let Some(detail) = bay.detail {
println!(" {detail}");
}
if let Some(manifest) = bay.manifest {
println!(
" {} {} {}",
manifest.cartridge.class.label(),
manifest.cartridge.id,
manifest.cartridge.version
);
}
if let Some(catalogue) = bay.software {
for software in catalogue.software {
println!(
" SOFTWARE {} {} (partition {})",
software.id, software.version, software.partition
);
for command in software.commands.keys() {
println!(" fds run {} -- {}:{}", bay.bay, software.id, command);
}
}
}
if let Some(mount) = bay.mount {
println!(" MOUNT {mount}");
}
if bay.consumers > 0 {
println!(" MANAGED PROCESSES {}", bay.consumers);
}
if debug {
for device in bay.devices {
println!(
" {} USB {}:{}",
device.topology, device.vendor, device.product
);
}
}
}
if debug {
for device in response.unmapped {
println!(
"UNMAPPED {} USB {}:{}",
device.topology, device.vendor, device.product
);
}
}
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fds: {error}");
ExitCode::from(2)
}
}
}
+134
View File
@@ -0,0 +1,134 @@
use fds_common::{
Error, Result,
control::{self, PowerEvent, PowerState, Request},
trace,
};
use std::{
ffi::CString,
fs, io,
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::fs::PermissionsExt,
},
path::Path,
};
const DIRECTORY: &str = "/run/fds/power";
const RECORD: &str = "/run/fds/power/state.json";
pub fn client(request: Request, json: bool) -> Result<()> {
let power = control::request(&request)?
.power
.ok_or_else(|| Error("Missing shutdown state".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&power).map_err(|e| Error(e.to_string()))?
);
} else {
println!("POWER STATE: {}", power.phase);
if let Some(action) = &power.action {
println!("ACTION: {action}");
}
if power.native_pending {
println!("Native s6 shutdown has been requested.");
}
if let Some(error) = &power.error {
println!(
"BLOCKED: {error}\nResolve the problem and retry, or use fds power resume before native shutdown starts."
);
}
}
Ok(())
}
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
Err(Error("This native shutdown helper requires root".into()))
} else {
Ok(())
}
}
pub fn hold_shutdown() -> Result<()> {
root()?;
// Last-resort fail-closed path used only if the state watcher itself fails.
// A blocked signal wait consumes no CPU and does not advance native init.
let mut mask = unsafe { std::mem::zeroed() };
unsafe {
libc::sigfillset(&mut mask);
if libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) < 0 {
return Err(io::Error::last_os_error().into());
}
loop {
libc::pause();
}
}
}
fn state() -> Result<PowerState> {
serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?)
.map_err(|e| Error(format!("Invalid shutdown record: {e}")))
}
pub fn shutdown_hook() -> Result<()> {
root()?;
fs::create_dir_all(DIRECTORY)?;
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error().into());
}
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let path = CString::new(DIRECTORY).unwrap();
if unsafe {
libc::inotify_add_watch(
fd.as_raw_fd(),
path.as_ptr(),
libc::IN_CLOSE_WRITE | libc::IN_MOVED_TO,
)
} < 0
{
return Err(io::Error::last_os_error().into());
}
// Record the irrevocable native request even if the cartridge daemon is
// currently restarting. Its next startup must keep operations frozen.
fs::write(
Path::new(DIRECTORY).join("native-pending"),
b"native shutdown requested\n",
)?;
if let Err(error) = control::request(&Request::PowerPrepare) {
eprintln!(
"Native shutdown is waiting: {error}. Resolve the problem and retry fds poweroff; DATA is not declared SAFE."
);
}
loop {
if state().is_ok_and(|s| s.phase == "prepared" && s.native_pending) {
return Ok(());
}
let mut bytes = [0u8; 4096];
let count = unsafe { libc::read(fd.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) };
if count < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
if count <= 0 {
return Err(Error("Shutdown state watcher failed".into()));
}
}
}
pub fn record_final(failed: bool) -> Result<()> {
root()?;
let mut record = state()?;
if record.phase != "prepared" {
return Err(Error("Shutdown preparation was not completed".into()));
}
record.events.push(PowerEvent {
phase: if failed {
"service_stop_failed"
} else {
"services_stopped"
}
.into(),
at_ns: trace::now()?,
});
println!(
"FDS_SHUTDOWN_FINAL {}",
serde_json::to_string(&record).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
+47
View File
@@ -0,0 +1,47 @@
use fds_common::{
Bay, Error, Result,
control::{self, Request},
};
pub fn run(bay: Bay, repair: bool, confirmation: Option<String>, json: bool) -> Result<()> {
let response = control::request(&Request::RecoveryData {
bay,
repair,
confirmation,
})?;
let report = response
.recovery
.ok_or_else(|| Error("Missing recovery response".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"BAY {} {} {} bytes\nSERIAL {}",
report.disk.bay,
report.disk.model,
report.disk.bytes,
report.disk.serial.as_deref().unwrap_or("unavailable")
);
if let Some(token) = report.confirmation {
println!(
"Repair preview: no filesystem changes made.\nAfter reviewing this device, run:\nfds recovery repair {bay} --confirm '{token}'"
);
} else {
println!(
"{}\nSAFE TO REMOVE",
if report.repaired {
"DATA REPAIRED AND VERIFIED"
} else {
"DATA CHECK PASSED"
}
);
}
if let Some(log) = report.log {
println!("LOG {log}");
}
}
Ok(())
}