use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use itertools::Itertools; use lazy_format::lazy_format; use rpc_toolkit::{CallRemoteHandler, Context, Empty, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use tokio::process::Command; use crate::{Error, ErrorKind}; use crate::context::{CliContext, RpcContext}; use crate::disk::util::DiskInfo; use crate::prelude::*; use crate::util::Invoke; use crate::util::serde::{HandlerExtSerde, WithIoFormat, display_serializable}; pub mod fsck; pub mod main; pub mod mount; pub mod util; pub const BOOT_RW_PATH: &str = "/media/boot-rw"; pub const REPAIR_DISK_PATH: &str = "/media/startos/config/repair-disk"; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct OsPartitionInfo { pub bios: Option, pub boot: PathBuf, pub root: PathBuf, #[serde(default)] pub extra_boot: BTreeMap, #[serde(skip)] pub data: Option, } impl OsPartitionInfo { pub fn contains(&self, logicalname: impl AsRef) -> bool { let p = logicalname.as_ref(); self.bios.as_deref() == Some(p) || p == &*self.boot || p == &*self.root || self.extra_boot.values().any(|v| v == p) } /// Build partition info by parsing /etc/fstab and resolving device specs, /// then discovering the BIOS boot partition (which is never mounted). pub async fn from_fstab() -> Result { let fstab = tokio::fs::read_to_string("/etc/fstab") .await .with_ctx(|_| (ErrorKind::Filesystem, "/etc/fstab"))?; let mut boot = None; let mut root = None; let mut extra_boot = BTreeMap::new(); for line in fstab.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let mut fields = line.split_whitespace(); let Some(source) = fields.next() else { continue; }; let Some(target) = fields.next() else { continue; }; let dev = match resolve_fstab_source(source).await { Ok(d) => d, Err(e) => { tracing::warn!("Failed to resolve fstab source {source}: {e}"); continue; } }; match target { "/" => root = Some(dev), "/boot" => boot = Some(dev), t if t.starts_with("/boot/") => { if let Some(name) = t.strip_prefix("/boot/") { extra_boot.insert(name.to_string(), dev); } } _ => {} } } let boot = boot.unwrap_or_default(); let bios = if !boot.as_os_str().is_empty() { find_bios_boot_partition(&boot).await.ok().flatten() } else { None }; Ok(Self { bios, boot, root: root.unwrap_or_default(), extra_boot, data: None, }) } } const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6e6f-744e-656564726548"; /// Find the BIOS boot partition on the same disk as `known_part`. async fn find_bios_boot_partition(known_part: &Path) -> Result, Error> { let output = Command::new("lsblk") .args(["-n", "-l", "-o", "NAME,PKNAME,PARTTYPE"]) .arg(known_part) .invoke(ErrorKind::DiskManagement) .await?; let text = String::from_utf8(output)?; let parent_disk = text.lines().find_map(|line| { let mut fields = line.split_whitespace(); let _name = fields.next()?; let pkname = fields.next()?; (!pkname.is_empty()).then(|| pkname.to_string()) }); let Some(parent_disk) = parent_disk else { return Ok(None); }; let output = Command::new("lsblk") .args(["-n", "-l", "-o", "NAME,PARTTYPE"]) .arg(format!("/dev/{parent_disk}")) .invoke(ErrorKind::DiskManagement) .await?; let text = String::from_utf8(output)?; for line in text.lines() { let mut fields = line.split_whitespace(); let Some(name) = fields.next() else { continue }; let Some(parttype) = fields.next() else { continue; }; if parttype.eq_ignore_ascii_case(BIOS_BOOT_TYPE_GUID) { return Ok(Some(PathBuf::from(format!("/dev/{name}")))); } } Ok(None) } /// Resolve an fstab device spec (e.g. /dev/sda1, PARTUUID=..., UUID=...) to a /// canonical device path. async fn resolve_fstab_source(source: &str) -> Result { if source.starts_with('/') { return Ok( tokio::fs::canonicalize(source) .await .unwrap_or_else(|_| PathBuf::from(source)), ); } // PARTUUID=, UUID=, LABEL= — resolve via blkid let output = Command::new("blkid") .args(["-o", "device", "-t", source]) .invoke(ErrorKind::DiskManagement) .await?; Ok(PathBuf::from(String::from_utf8(output)?.trim())) } pub fn disk() -> ParentHandler { ParentHandler::new() .subcommand( "list", from_fn_async(list) .with_display_serializable() .with_custom_display_fn(|handle, result| display_disk_info(handle.params, result)) .with_about("about.list-disk-info") .with_call_remote::(), ) .subcommand("repair", from_fn_async(|_: C| repair()).no_cli()) .subcommand( "repair", CallRemoteHandler::::new( from_fn_async(|_: RpcContext| repair()) .no_display() .with_about("about.repair-disk-corruption"), ), ) } fn display_disk_info(params: WithIoFormat, args: Vec) -> Result<(), Error> { use prettytable::*; if let Some(format) = params.format { return display_serializable(format, args); } let mut table = Table::new(); table.add_row(row![bc => "LOGICALNAME", "LABEL", "CAPACITY", "USED", "STARTOS VERSION" ]); for disk in args { let row = row![ disk.logicalname.display(), "N/A", &format!("{:.2} GiB", disk.capacity as f64 / 1024.0 / 1024.0 / 1024.0), "N/A", "N/A", ]; table.add_row(row); for part in disk.partitions { let row = row![ part.logicalname.display(), if let Some(label) = part.label.as_ref() { label } else { "N/A" }, part.capacity, &if let Some(used) = part .used .map(|u| format!("{:.2} GiB", u as f64 / 1024.0 / 1024.0 / 1024.0)) { used } else { "N/A".to_owned() }, &if part.start_os.is_empty() { "N/A".to_owned() } else if part.start_os.len() == 1 { part.start_os .first_key_value() .map(|(_, info)| info.version.to_string()) .unwrap() } else { part.start_os .iter() .map(|(id, info)| lazy_format!("{} ({})", info.version, id)) .join(", ") }, ]; table.add_row(row); } } table.print_tty(false)?; Ok(()) } // #[command(display(display_disk_info))] pub async fn list(ctx: RpcContext, _: Empty) -> Result, Error> { crate::disk::util::list(&ctx.os_partitions).await } pub async fn repair() -> Result<(), Error> { tokio::fs::write(REPAIR_DISK_PATH, b"").await?; Ok(()) }