Download
584 lines 18 KiBRustModified 2026-10-16 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
use anyhow::{Context, Result};
use serde::Serialize;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

#[derive(Debug, Clone, Default, Serialize)]
pub struct DriveInfo {
    pub lsblk_text: String,
    pub df_text: Option<String>,
    pub ls_text: Option<String>,
    pub note: Option<String>,
    pub finished: bool,
}

pub type SharedDriveInfo = Arc<Mutex<DriveInfo>>;

pub fn probe_drive(device: String, fstype: Option<String>) -> SharedDriveInfo {
    let shared = Arc::new(Mutex::new(DriveInfo::default()));
    let ret = Arc::clone(&shared);
    std::thread::spawn(move || {
        *shared.lock().unwrap() = do_probe(&device, fstype.as_deref());
    });
    ret
}

fn get_mountpoint(device: &str) -> Option<std::path::PathBuf> {
    let out = Command::new("lsblk")
        .args(["-n", "-o", "MOUNTPOINT", device])
        .stdin(Stdio::null())
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    text.lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .map(std::path::PathBuf::from)
}

fn do_probe(device: &str, fstype: Option<&str>) -> DriveInfo {
    let mut info = DriveInfo::default();

    if let Ok(out) = Command::new("lsblk")
        .args(["-o", "NAME,SIZE,FSTYPE,LABEL,HOTPLUG,TYPE", device])
        .stdin(Stdio::null())
        .output()
    {
        info.lsblk_text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
    }

    match fstype {
        Some("crypto_LUKS") => {
            info.note = Some(
                "Encrypted (LUKS) — contents cannot be read without the passphrase.".to_owned(),
            );
            info.finished = true;
            return info;
        }
        None | Some("") => {
            info.note = Some("No filesystem detected on this device.".to_owned());
            info.finished = true;
            return info;
        }
        _ => {}
    }

    let existing_mp = get_mountpoint(device);
    let (mp, we_mounted) = if let Some(mp) = existing_mp {
        (mp, false)
    } else {
        match crate::drives::mount_device(device) {
            Err(e) => {
                info.note = Some(format!("Could not mount for preview: {e}"));
                info.finished = true;
                return info;
            }
            Ok(mp) => (mp, true),
        }
    };

    let mp_str = mp.to_string_lossy();
    if let Ok(out) = Command::new("df")
        .args(["-h", &*mp_str])
        .stdin(Stdio::null())
        .output()
    {
        let text = String::from_utf8_lossy(&out.stdout).to_string();
        if !text.trim().is_empty() {
            info.df_text = Some(text);
        }
    }
    if let Ok(out) = Command::new("ls")
        .args(["-lAh", &*mp_str])
        .stdin(Stdio::null())
        .output()
    {
        let text = String::from_utf8_lossy(&out.stdout).to_string();
        if !text.trim().is_empty() {
            info.ls_text = Some(text);
        }
    }
    if we_mounted {
        let _ = crate::drives::udisksctl_unmount(device);
    }

    info.finished = true;
    info
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct FormatProgress {
    pub step: usize,
    pub total_steps: usize,
    pub step_name: String,
    pub log: Vec<String>,
    pub finished: bool,
    pub error: Option<String>,
}

pub type SharedFormatProgress = Arc<Mutex<FormatProgress>>;

fn log(progress: &SharedFormatProgress, msg: &str) {
    progress.lock().unwrap().log.push(msg.to_owned());
}

fn advance(progress: &SharedFormatProgress, name: &str) {
    let mut p = progress.lock().unwrap();
    p.step += 1;
    p.step_name = name.to_owned();
    p.log.push(format!(">>> {name}"));
}

fn append_output(progress: &SharedFormatProgress, stdout: &[u8], stderr: &[u8]) {
    let mut p = progress.lock().unwrap();
    for line in String::from_utf8_lossy(stdout).lines() {
        let line = line.trim();
        if !line.is_empty() {
            p.log.push(line.to_owned());
        }
    }
    for line in String::from_utf8_lossy(stderr).lines() {
        let line = line.trim();
        if !line.is_empty() {
            p.log.push(line.to_owned());
        }
    }
}

fn doas_run(args: &[&str], progress: &SharedFormatProgress) -> Result<()> {
    log(progress, &format!("$ doas {}", args.join(" ")));
    let out = Command::new("doas")
        .args(args)
        .stdin(Stdio::null())
        .output()
        .with_context(|| format!("failed to spawn: doas {}", args.join(" ")))?;
    append_output(progress, &out.stdout, &out.stderr);
    if !out.status.success() {
        anyhow::bail!(
            "doas {} failed (exit {}): {}",
            args.join(" "),
            out.status.code().unwrap_or(-1),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(())
}

fn doas_with_passphrase(
    args: &[&str],
    passphrase: &str,
    progress: &SharedFormatProgress,
) -> Result<()> {
    log(progress, &format!("$ doas {}", args.join(" ")));
    let mut child = Command::new("doas")
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .with_context(|| format!("failed to spawn: doas {}", args.join(" ")))?;
    if let Some(mut stdin) = child.stdin.take() {
        let _ = stdin.write_all(passphrase.as_bytes());
    }
    let out = child.wait_with_output().context("wait_with_output")?;
    append_output(progress, &out.stdout, &out.stderr);
    if !out.status.success() {
        anyhow::bail!(
            "doas {} failed (exit {}): {}",
            args.join(" "),
            out.status.code().unwrap_or(-1),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(())
}

fn device_is_mounted(dev: &str, mounts: &str) -> bool {
    let canonical = std::fs::canonicalize(dev).ok();
    mounts.lines().any(|line| {
        let mount_dev = line.split_whitespace().next().unwrap_or("");
        if mount_dev == dev {
            return true;
        }
        if let Some(ref c) = canonical {
            if let Ok(mc) = std::fs::canonicalize(mount_dev) {
                return &mc == c;
            }
        }
        false
    })
}

fn find_mounted_device(device: &str, is_disk: bool) -> Option<String> {
    let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
    if device_is_mounted(device, &mounts) {
        return Some(device.to_owned());
    }
    if is_disk {
        let part = crate::drives::partition_path(device);
        if Path::new(&part).exists() && device_is_mounted(&part, &mounts) {
            return Some(part);
        }
    }
    None
}

fn validate_device(device: &str, is_disk: bool, progress: &SharedFormatProgress) -> Result<()> {
    use std::os::unix::fs::FileTypeExt;

    log(progress, "--- Pre-flight checks ---");

    let meta = std::fs::metadata(device)
        .with_context(|| format!("Cannot access {device}: file not found or permission denied"))?;
    if !meta.file_type().is_block_device() {
        anyhow::bail!("{device} is not a block device");
    }
    log(
        progress,
        &format!("✓ {device} exists and is a block device"),
    );

    let lsblk_out = Command::new("lsblk")
        .args(["-J", "-o", "HOTPLUG,RM,TYPE", device])
        .stdin(Stdio::null())
        .output()
        .context("lsblk re-check failed")?;
    let lsblk_json: serde_json::Value =
        serde_json::from_slice(&lsblk_out.stdout).context("lsblk output could not be parsed")?;
    let dev_info = &lsblk_json["blockdevices"][0];
    let hotplug = dev_info["hotplug"].as_bool().unwrap_or(false)
        || dev_info["hotplug"].as_str() == Some("1")
        || dev_info["rm"].as_bool().unwrap_or(false)
        || dev_info["rm"].as_str() == Some("1");
    if !hotplug {
        anyhow::bail!(
            "SAFETY ABORT: {device} is not a hotplug/removable device. \
             Refusing to format a drive that may be an internal system disk."
        );
    }
    log(progress, &format!("✓ {device} is hotplug/removable"));

    let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();

    if device_is_mounted(device, &mounts) {
        anyhow::bail!(
            "SAFETY ABORT: {device} is currently mounted. Unmount or eject it before formatting."
        );
    }
    if is_disk {
        let partition = crate::drives::partition_path(device);
        if Path::new(&partition).exists() && device_is_mounted(&partition, &mounts) {
            anyhow::bail!(
                "SAFETY ABORT: partition {partition} is currently mounted. \
                 Unmount or eject it before formatting."
            );
        }
    }
    log(progress, &format!("✓ {device} is not mounted"));

    let mapper_path = "/dev/mapper/backer-upper-format";
    if Path::new(mapper_path).exists() {
        anyhow::bail!(
            "SAFETY ABORT: {mapper_path} already exists, likely left open by a previous failed \
             format attempt. Close it first:\n  doas cryptsetup luksClose backer-upper-format"
        );
    }
    log(progress, "✓ Mapper slot is free");
    log(progress, "--- All checks passed, starting format ---");
    Ok(())
}

// A single step in the format sequence. Both the executor and the preview are
// derived from the same Vec<FormatStep>, so they cannot diverge.
// `preview` is the line shown to the user; None means an internal-only step.
struct FormatStep {
    preview: Option<String>,
    action: FormatAction,
}

enum FormatAction {
    Advance(String),
    Doas {
        args: Vec<String>,
        needs_passphrase: bool,
        is_cleanup: bool,
    },
    WaitForDevice(String),
    CheckDevice(String),
}

fn doas_step(args: &[&str]) -> FormatStep {
    let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
    FormatStep {
        preview: Some(format!("doas {}", args.join(" "))),
        action: FormatAction::Doas {
            args,
            needs_passphrase: false,
            is_cleanup: false,
        },
    }
}

fn doas_passphrase_step(args: &[&str]) -> FormatStep {
    let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
    FormatStep {
        preview: Some(format!("doas {}", args.join(" "))),
        action: FormatAction::Doas {
            args,
            needs_passphrase: true,
            is_cleanup: false,
        },
    }
}

fn doas_cleanup_step(args: &[&str]) -> FormatStep {
    let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
    FormatStep {
        preview: Some(format!("doas {}", args.join(" "))),
        action: FormatAction::Doas {
            args,
            needs_passphrase: false,
            is_cleanup: true,
        },
    }
}

fn build_format_steps(
    device: &str,
    is_disk: bool,
    label: &str,
    fstype: &str,
    encrypt: bool,
) -> Vec<FormatStep> {
    let no_preview = |action| FormatStep {
        preview: None,
        action,
    };
    let mapper = "backer-upper-format";
    let mapper_dev = format!("/dev/mapper/{mapper}");
    let mkfs_cmd = format!("mkfs.{fstype}");
    let format_step_name = format!("Formatting filesystem ({fstype})");
    let mut steps: Vec<FormatStep> = Vec::new();

    let partition = if is_disk {
        let part = crate::drives::partition_path(device);
        steps.push(no_preview(FormatAction::Advance("Clearing drive".into())));
        steps.push(doas_step(&["wipefs", "-a", device]));
        steps.push(no_preview(FormatAction::Advance(
            "Creating partition".into(),
        )));
        steps.push(doas_step(&[
            "parted", "-s", device, "mklabel", "gpt", "mkpart", "primary", "0%", "100%",
        ]));
        steps.push(no_preview(FormatAction::WaitForDevice(part.clone())));
        part
    } else {
        steps.push(no_preview(FormatAction::Advance(
            "Clearing partition".into(),
        )));
        steps.push(doas_step(&["wipefs", "-a", device]));
        device.to_owned()
    };

    if encrypt {
        steps.push(no_preview(FormatAction::Advance(
            "Encrypting with LUKS".into(),
        )));
        steps.push(doas_passphrase_step(&[
            "cryptsetup",
            "luksFormat",
            "--type",
            "luks2",
            "--batch-mode",
            "--key-file",
            "-",
            &partition,
        ]));
        steps.push(no_preview(FormatAction::Advance(format_step_name)));
        steps.push(doas_passphrase_step(&[
            "cryptsetup",
            "luksOpen",
            "--key-file",
            "-",
            &partition,
            mapper,
        ]));
        steps.push(no_preview(FormatAction::CheckDevice(mapper_dev.clone())));
        steps.push(doas_step(&[&mkfs_cmd, "-L", label, &mapper_dev]));
        steps.push(doas_cleanup_step(&["cryptsetup", "luksClose", mapper]));
    } else {
        steps.push(no_preview(FormatAction::Advance(format_step_name)));
        steps.push(doas_step(&[&mkfs_cmd, "-L", label, &partition]));
    }

    steps
}

pub fn format_command_preview(
    device: &str,
    is_disk: bool,
    label: &str,
    fstype: &str,
    encrypt: bool,
) -> Vec<String> {
    build_format_steps(device, is_disk, label, fstype, encrypt)
        .into_iter()
        .filter_map(|step| step.preview)
        .collect()
}

fn do_format(
    device: &str,
    is_disk: bool,
    label: &str,
    fstype: &str,
    encrypt: bool,
    passphrase: &str,
    progress: &SharedFormatProgress,
) -> Result<()> {
    if let Some(mounted_dev) = find_mounted_device(device, is_disk) {
        progress.lock().unwrap().total_steps += 1;
        advance(progress, "Unmounting drive");
        crate::drives::udisksctl_unmount(&mounted_dev)
            .with_context(|| format!("Failed to unmount {mounted_dev} before formatting"))?;
    }

    validate_device(device, is_disk, progress)?;

    let steps = build_format_steps(device, is_disk, label, fstype, encrypt);
    let mut pending_error: Option<anyhow::Error> = None;

    for step in steps {
        match step.action {
            FormatAction::Advance(name) => {
                if pending_error.is_none() {
                    advance(progress, &name);
                }
            }
            FormatAction::Doas {
                args,
                needs_passphrase,
                is_cleanup,
            } => {
                if pending_error.is_some() && !is_cleanup {
                    continue;
                }
                let refs: Vec<&str> = args.iter().map(|a| a.as_str()).collect();
                let result = if needs_passphrase {
                    doas_with_passphrase(&refs, passphrase, progress)
                } else {
                    doas_run(&refs, progress)
                };
                if let Err(e) = result {
                    if pending_error.is_none() && !is_cleanup {
                        pending_error = Some(e);
                    }
                }
            }
            FormatAction::WaitForDevice(path) => {
                if pending_error.is_some() {
                    continue;
                }
                log(progress, &format!("Waiting for {path} to appear…"));
                let mut appeared = false;
                for _ in 0..20 {
                    let _ = Command::new("doas")
                        .args(["udevadm", "settle"])
                        .stdin(Stdio::null())
                        .status();
                    if Path::new(&path).exists() {
                        appeared = true;
                        break;
                    }
                    std::thread::sleep(std::time::Duration::from_millis(500));
                }
                if appeared {
                    log(progress, &format!("✓ {path} is ready"));
                } else {
                    pending_error = Some(anyhow::anyhow!(
                        "Partition device {path} did not appear after partitioning. \
                         Try replugging the drive and running again."
                    ));
                }
            }
            FormatAction::CheckDevice(path) => {
                if pending_error.is_some() {
                    continue;
                }
                if Path::new(&path).exists() {
                    log(progress, &format!("✓ {path} is ready"));
                } else {
                    pending_error = Some(anyhow::anyhow!(
                        "Mapper device {path} did not appear after luksOpen. Cannot continue."
                    ));
                }
            }
        }
    }

    if pending_error.is_none() {
        let _ = Command::new("doas")
            .args(["udevadm", "trigger", "--action=change", device])
            .stdin(Stdio::null())
            .status();
        let _ = Command::new("doas")
            .args(["udevadm", "settle"])
            .stdin(Stdio::null())
            .status();
    }

    pending_error.map_or(Ok(()), Err)
}

pub fn run_format(
    device: String,
    is_disk: bool,
    label: String,
    fstype: String,
    encrypt: bool,
    passphrase: String,
    progress: SharedFormatProgress,
) -> std::thread::JoinHandle<()> {
    let total = build_format_steps(&device, is_disk, &label, &fstype, encrypt)
        .iter()
        .filter(|s| matches!(s.action, FormatAction::Advance(_)))
        .count();
    {
        let mut p = progress.lock().unwrap();
        *p = FormatProgress {
            total_steps: total,
            ..Default::default()
        };
    }
    std::thread::spawn(move || {
        match do_format(
            &device,
            is_disk,
            &label,
            &fstype,
            encrypt,
            &passphrase,
            &progress,
        ) {
            Ok(()) => {
                let mut p = progress.lock().unwrap();
                p.finished = true;
                p.step = p.total_steps;
                p.step_name = "Done".to_owned();
                p.log.push(String::new());
                p.log.push(format!(
                    "✓ Drive '{label}' is ready. Select it to start backing up."
                ));
            }
            Err(e) => {
                let mut p = progress.lock().unwrap();
                p.error = Some(e.to_string());
                p.finished = true;
                p.log.push(String::new());
                p.log.push(format!("✗ Format failed: {e}"));
            }
        }
    })
}