From 8e6475cfa96af3a62b26ab51363f8377bef38f3d Mon Sep 17 00:00:00 2001 From: OmarGonD Date: Thu, 29 Oct 2026 16:05:05 -0500 Subject: [PATCH] Add Linux partition resizing through GParted --- README.md | 22 ++ crates/disktree-app/src/main.rs | 2 + crates/disktree-app/src/resize.rs | 178 +++++++++++++ crates/disktree-app/src/state.rs | 32 ++- crates/disktree-app/src/tests.rs | 142 ++++++++++ crates/disktree-app/src/views.rs | 27 +- crates/disktree-core/src/lib.rs | 2 + crates/disktree-core/src/partition_editor.rs | 256 +++++++++++++++++++ 8 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 crates/disktree-app/src/resize.rs create mode 100644 crates/disktree-core/src/partition_editor.rs diff --git a/README.md b/README.md index bf9bc21f..6466a714 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,28 @@ Built with [GPUI](https://gpui-kit.com/) through [gpui-omarchy](https://github.com/huacnlee/gpui-omarchy), so it follows your Omarchy theme and behaves like the rest of the desktop. +## Resize a partition (Linux) + +The Disk panel's **Resize partition…** action opens the local volume's +physical disk in [GParted](https://gparted.org/). Select the partition in +GParted, choose **Resize/Move** to grow or shrink it, then review and apply +the queued operations. Opening the editor does not change the disk. +Install `gparted` separately; it is an optional dependency and handles its +own administrator authentication. Disktree stays unprivileged and refreshes +the scan after the editor closes. + +Growing needs adjacent unallocated space. Shrinking depends on filesystem +support; free space inside another partition is not unallocated space. +Back up important files before resizing. Use a live USB when the system +partition is busy or an operation requires unmounting. LUKS volumes must be +unlocked in GParted; support depends on the complete storage layout. This +does not change a physical disk's capacity. See the +[GParted manual](https://gparted.org/display-doc.php?name=help-manual). + +This action is for local Linux disks and simple partition/LUKS layouts. +SSH sessions remain read-only. RAID, LVM, loop devices and ambiguous device +ancestry are refused rather than selecting an arbitrary disk. + ## Install Download `disktree-*-x86_64-linux.tar.gz` (`aarch64-linux` on ARM) from the diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 904a9319..b7c930d9 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -17,6 +17,8 @@ mod git; mod marks; mod palette; mod power; +#[cfg(target_os = "linux")] +mod resize; mod state; #[cfg(test)] mod tests; diff --git a/crates/disktree-app/src/resize.rs b/crates/disktree-app/src/resize.rs new file mode 100644 index 00000000..0f5de3e4 --- /dev/null +++ b/crates/disktree-app/src/resize.rs @@ -0,0 +1,178 @@ +//! Linux partition resizing is handed to `GParted`, never to shell snippets. + +use disktree_core::partition_editor::{self, Target}; +use gpui_kit::{ + Context, InteractiveElement as _, IntoElement, ParentElement, Styled, div, +}; +use gpui_omarchy::{ + ButtonVariant, Status, alert_dialog, dialog_button, dialog_description, + dialog_popup, dialog_title, +}; + +use crate::state::{Disktree, FocusTarget}; +use crate::ui::{size, space}; +use crate::views::centred_popup; + +#[derive(Debug)] +pub enum Dialog { + Loading, + Ready(Target), + Editing, + Failed(String), +} + +impl Disktree { + pub fn open_resize(&mut self, cx: &mut Context<'_, Self>) { + if !self.supports_resize() || !self.can_start_over() { + return; + } + self.resize_epoch += 1; + let epoch = self.resize_epoch; + self.resize_dialog = Some(Dialog::Loading); + self.focus_request = Some(FocusTarget::Dialog); + let root = self.root_path.clone(); + let task = cx.background_executor().spawn(async move { + partition_editor::prepare(&root).map_err(|error| error.to_string()) + }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + if this.resize_epoch == epoch && this.resize_open() { + this.resize_dialog = Some(match result { + Ok(target) => Dialog::Ready(target), + Err(error) => Dialog::Failed(error), + }); + cx.notify(); + } + }); + }) + .detach(); + cx.notify(); + } + + pub fn close_resize(&mut self, cx: &mut Context<'_, Self>) { + if matches!(self.resize_dialog, Some(Dialog::Editing)) { + return; + } + self.resize_epoch += 1; + self.resize_dialog = None; + self.focus_request = Some(FocusTarget::Root); + cx.notify(); + } + + pub fn launch_partition_editor(&mut self, cx: &mut Context<'_, Self>) { + // Enter while discovery is pending or after launch cannot start an + // editor. A local Ready target is the only actionable state. + if self.remote.is_some() { + return; + } + let Some(Dialog::Ready(target)) = &self.resize_dialog else { + return; + }; + let target = target.clone(); + self.resize_dialog = Some(Dialog::Editing); + let task = cx.background_executor().spawn(async move { + partition_editor::open(&target).map_err(|error| error.to_string()) + }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(()) => { + this.resize_dialog = None; + this.focus_request = Some(FocusTarget::Root); + this.notice = Some(( + "Partition editor closed; refreshing disk usage." + .into(), + Status::Neutral, + )); + this.start_scan(cx); + } + Err(error) => { + this.resize_dialog = Some(Dialog::Failed(error)); + } + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } +} + +pub fn dialog( + app: &Disktree, + cx: &mut Context<'_, Disktree>, +) -> impl IntoElement { + let ready = matches!(app.resize_dialog, Some(Dialog::Ready(_))); + let editing = matches!(app.resize_dialog, Some(Dialog::Editing)); + let message = match &app.resize_dialog { + Some(Dialog::Ready(target)) => format!( + "Open {} in GParted to grow or shrink {}. \ + Select it there, choose Resize/Move, then review \ + and apply the queued changes.", + target.disk.display(), + target.partition.as_ref().map_or_else( + || "a partition".into(), + |partition| partition.display().to_string() + ) + ), + Some(Dialog::Loading) => { + "Finding the disk and partition editor…".into() + } + Some(Dialog::Editing) => "GParted is open. Close it to return here; \ + disk usage will refresh afterward." + .into(), + Some(Dialog::Failed(error)) => error.clone(), + None => String::new(), + }; + let popup = dialog_popup(cx) + .w(size::SOURCE_PICKER) + .child(dialog_title("Resize partition", cx)) + .child(dialog_description(message, cx)) + .child(dialog_description( + "Growing needs adjacent unallocated space. Shrinking depends on \ + the filesystem. Resizing does not change a physical disk's capacity.", cx)) + .child(dialog_description( + "Back up important files first. Use a live USB if the system \ + partition is busy or the operation requires unmounting. \ + Encrypted volumes must be unlocked in GParted; support depends \ + on the storage layout. No changes are applied by opening the editor.", cx)) + .child(div().flex().flex_row().justify_end().gap(space::SM) + .child(dialog_button("resize-cancel", "Close", ButtonVariant::Secondary, cx) + .disabled(editing) + .debug_selector(|| "resize-cancel".into()) + .on_click(cx.listener(|this, _, window, cx| { + this.close_resize(cx); + this.apply_focus(window, cx); + }))) + .child(dialog_button("resize-open", "Open GParted", ButtonVariant::Primary, cx) + .disabled(!ready) + .debug_selector(|| "resize-open".into()) + .on_click(cx.listener(|this, _, _, cx| this.launch_partition_editor(cx))))); + let cancel = cx.entity().downgrade(); + let outside = cancel.clone(); + let accept = cancel.clone(); + let popup = div().debug_selector(|| "resize-dialog".into()).child(popup); + alert_dialog(&app.confirm_focus, cx) + .open(true) + .on_ok(move |_, _, cx| { + let _ = accept.update(cx, |this, cx| { + this.launch_partition_editor(cx); + }); + false + }) + .on_cancel(move |_, window, cx| { + let _ = cancel.update(cx, |this, cx| { + this.close_resize(cx); + this.apply_focus(window, cx); + }); + false + }) + .popup(centred_popup(popup, move |_, window, cx| { + let _ = outside.update(cx, |this, cx| { + this.close_resize(cx); + this.apply_focus(window, cx); + }); + })) +} diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index efb9730a..323d94af 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -382,6 +382,10 @@ pub struct Disktree { /// The permanent-deletion alert dialog is open. Trash needs no dialog: it /// is reversible, so it commits directly. pub confirm_open: bool, + #[cfg(target_os = "linux")] + pub resize_dialog: Option, + #[cfg(target_os = "linux")] + pub resize_epoch: u64, /// Focus owner for the alert dialog while it is open. pub confirm_focus: FocusHandle, /// Focus to move on the next occasion a window is in hand. Key handling @@ -572,6 +576,10 @@ impl Disktree { }, trash_backend, confirm_open: false, + #[cfg(target_os = "linux")] + resize_dialog: None, + #[cfg(target_os = "linux")] + resize_epoch: 0, confirm_focus: cx.focus_handle(), focus_request: None, window_title: String::new(), @@ -737,7 +745,10 @@ impl Disktree { /// is left alone until a volume is picked; picking calls [`set_root`], /// which cancels it. pub fn open_volumes(&mut self, cx: &mut Context<'_, Self>) { - if self.run.is_some() || self.screen != Screen::Explore { + if self.run.is_some() + || self.screen != Screen::Explore + || self.resize_open() + { return; } self.volumes_open = true; @@ -2843,6 +2854,23 @@ impl Disktree { self.screen == Screen::Explore && !self.confirm_open && !self.volumes_open + && !self.resize_open() + } + + pub const fn resize_open(&self) -> bool { + #[cfg(target_os = "linux")] + return self.resize_dialog.is_some(); + #[cfg(not(target_os = "linux"))] + false + } + + #[cfg(target_os = "linux")] + pub fn supports_resize(&self) -> bool { + self.remote.is_none() + && self + .device + .as_deref() + .is_some_and(|d| d.starts_with("/dev/")) } /// Ask for a directory and scan it: an app opened from the Dock has no @@ -3049,7 +3077,7 @@ impl Disktree { // The alert dialog owns Enter and Escape while it is open; a key that // bubbles up to here must not also act on the screen behind it. - if self.confirm_open { + if self.confirm_open || self.resize_open() { return; } diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 0520e4cb..0e23f915 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -92,6 +92,148 @@ fn read( view.read_with(cx, |app, _| f(app)) } +#[cfg(target_os = "linux")] +#[gpui_kit::test] +fn resize_dialog_owns_keys_and_cancels_without_touching_files( + cx: &mut TestAppContext, +) { + use disktree_core::partition_editor::Target; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, _| { + app.device = Some("/dev/mapper/root".into()); + app.resize_dialog = Some(crate::resize::Dialog::Ready(Target { + disk: "/dev/nvme0n1".into(), + partition: Some("/dev/nvme0n1p5".into()), + // A mistaken implicit launch must fail this test, never open a + // real editor or authenticate against the machine running it. + editor: "/no-such-partition-editor".into(), + })); + app.focus_request = Some(crate::state::FocusTarget::Dialog); + }); + draw(cx); + assert!(cx.debug_bounds("resize-dialog").is_some()); + assert!(!read(&view, cx, Disktree::can_start_over)); + for key in ["space", "c", "v", "r"] { + press(cx, key); + assert!(read(&view, cx, |app| matches!( + app.resize_dialog, + Some(crate::resize::Dialog::Ready(_)) + ))); + assert!(read(&view, cx, |app| app.marks.is_empty())); + assert!(!read(&view, cx, |app| app.volumes_open)); + } + press(cx, "escape"); + assert!(!read(&view, cx, Disktree::resize_open)); + assert!(read(&view, cx, Disktree::can_start_over)); + assert!(temp.path().join("junk/blob.bin").exists()); +} + +#[cfg(target_os = "linux")] +#[gpui_kit::test] +fn resize_is_unavailable_for_ssh_and_non_block_mounts(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, _| { + app.device = Some("/dev/nvme0n1p5".into()); + app.remote = Some("fixture-only".into()); + }); + draw(cx); + assert!(cx.debug_bounds("resize-partition").is_none()); + update(&view, cx, Disktree::open_resize); + assert!(!read(&view, cx, Disktree::resize_open)); + update(&view, cx, |app, _| { + app.remote = None; + app.device = Some("tmpfs".into()); + }); + draw(cx); + assert!(cx.debug_bounds("resize-partition").is_none()); + update(&view, cx, Disktree::open_resize); + assert!(!read(&view, cx, Disktree::resize_open)); +} + +#[cfg(target_os = "linux")] +#[gpui_kit::test] +fn resize_discovery_and_errors_never_launch_an_editor(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + for state in [ + crate::resize::Dialog::Loading, + crate::resize::Dialog::Failed("Install GParted".into()), + ] { + update(&view, cx, |app, _| { + app.resize_dialog = Some(state); + app.focus_request = Some(crate::state::FocusTarget::Dialog); + }); + draw(cx); + assert!(cx.debug_bounds("resize-open").is_some()); + update(&view, cx, Disktree::launch_partition_editor); + press(cx, "enter"); + assert!(read(&view, cx, Disktree::resize_open)); + let close = cx.debug_bounds("resize-cancel").unwrap(); + cx.simulate_click(close.center(), gpui_kit::Modifiers::none()); + draw(cx); + assert!(!read(&view, cx, Disktree::resize_open)); + } +} + +#[cfg(target_os = "linux")] +#[gpui_kit::test] +fn cancelled_resize_discovery_cannot_reopen_the_dialog( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, cx| { + app.device = Some("/dev/mapper/root".into()); + app.open_resize(cx); + assert!(app.resize_open()); + app.close_resize(cx); + }); + cx.run_until_parked(); + assert!(!read(&view, cx, Disktree::resize_open)); +} + +#[cfg(target_os = "linux")] +#[gpui_kit::test] +fn editor_failure_is_shown_and_success_refreshes_the_scan( + cx: &mut TestAppContext, +) { + use disktree_core::partition_editor::Target; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + for (editor, success) in [("/bin/false", false), ("/bin/true", true)] { + update(&view, cx, |app, _| { + app.resize_dialog = Some(crate::resize::Dialog::Ready(Target { + disk: "/dev/fixture-never-opened".into(), + partition: None, + editor: editor.into(), + })); + app.focus_request = Some(crate::state::FocusTarget::Dialog); + }); + draw(cx); + press(cx, "enter"); + cx.run_until_parked(); + if success { + assert!(!read(&view, cx, Disktree::resize_open)); + finish_scan(&view, cx); + } else { + assert!(read(&view, cx, |app| matches!( + &app.resize_dialog, + Some(crate::resize::Dialog::Failed(error)) if error.contains("exit") + ))); + } + } + assert!(temp.path().join("junk/blob.bin").exists()); +} + #[gpui_kit::test] fn remote_indexing_draws_live_activity_and_the_sampled_directory( cx: &mut TestAppContext, diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index 32aedf5f..21cb8321 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -81,13 +81,13 @@ pub fn root( )) // Only where the history buttons are: the review screen has none. .on_action(cx.listener(|this, _: &crate::app_menu::GoBack, _, cx| { - if this.screen == Screen::Explore { + if this.screen == Screen::Explore && !this.resize_open() { this.go_back(cx); } })) .on_action(cx.listener( |this, _: &crate::app_menu::GoForward, _, cx| { - if this.screen == Screen::Explore { + if this.screen == Screen::Explore && !this.resize_open() { this.go_forward(cx); } }, @@ -126,6 +126,10 @@ pub fn root( if app.confirm_open { root = root.child(delete_dialog(app, cx)); } + #[cfg(target_os = "linux")] + if app.resize_open() { + root = root.child(crate::resize::dialog(app, cx)); + } root } @@ -301,7 +305,7 @@ fn volumes_dialog( /// the click that dismisses a dialog, so the click is taken here instead. The /// card stops a mouse-down from reaching this wrapper, so a click on the /// dialog itself stays the dialog's own. -fn centred_popup( +pub fn centred_popup( card: impl IntoElement, on_outside: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> Stateful
{ @@ -2180,6 +2184,23 @@ fn disk_section( })), ); let mut section = div().flex().flex_col().gap(space::SM).child(header); + #[cfg(target_os = "linux")] + if app.supports_resize() { + section = section.child( + button( + "resize-partition", + "Resize partition…", + ButtonVariant::Secondary, + cx, + ) + .debug_selector(|| "resize-partition".into()) + .tab_stop(false) + .on_click(cx.listener(|this, _, window, cx| { + this.open_resize(cx); + this.apply_focus(window, cx); + })), + ); + } let Some(space_info) = app.space else { return section.child( div() diff --git a/crates/disktree-core/src/lib.rs b/crates/disktree-core/src/lib.rs index 0f58c7ba..b59f91e9 100644 --- a/crates/disktree-core/src/lib.rs +++ b/crates/disktree-core/src/lib.rs @@ -16,6 +16,8 @@ pub mod insights; mod macos; #[cfg(windows)] mod mft; +#[cfg(target_os = "linux")] +pub mod partition_editor; pub mod payload; pub mod remote; pub mod remote_progress; diff --git a/crates/disktree-core/src/partition_editor.rs b/crates/disktree-core/src/partition_editor.rs new file mode 100644 index 00000000..8accbe20 --- /dev/null +++ b/crates/disktree-core/src/partition_editor.rs @@ -0,0 +1,256 @@ +//! Hand a local disk to `GParted`, which owns validation and applying changes. +//! +//! Discovery is read-only. No partition, filesystem or encryption command is +//! run by disktree; the editor asks for its own authorization and confirmation. + +use std::io; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::Deserialize; + +/// An editor and the single physical disk backing a local mount. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub disk: PathBuf, + pub partition: Option, + pub editor: PathBuf, +} + +/// Resolve the current mount rather than trusting a cached device label. +pub fn prepare(root: &Path) -> io::Result { + let editor = std::env::var_os("PATH") + .into_iter() + .flat_map(|paths| std::env::split_paths(&paths).collect::>()) + .map(|path| path.join("gparted")) + .find(|path| { + path.metadata().is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Install GParted to resize partitions, then try again.", + ) + })?; + let mount = output( + Command::new("findmnt") + // Btrfs mount IDs can have a virtual major:minor pair; use the + // real source and omit the subvolume suffix (`[/@home]`). JSON + // preserves device names containing whitespace without splitting. + .args([ + "--json", "--evaluate", "--nofsroot", "--output", "SOURCE", + "--target", + ]) + .arg(root), + )?; + let device = device_path(&mount)?; + let topology = output( + Command::new("lsblk") + .args([ + "--inverse", + // PATH alone does not imply tree-shaped JSON. + "--tree", + "--json", + "--paths", + "--output", + "PATH,TYPE", + "--", + ]) + .arg(device), + )?; + let (disk, partition) = physical_disk(&topology)?; + Ok(Target { + disk, + partition, + editor, + }) +} + +/// Wait off the UI thread, so the caller can refresh after the editor exits. +pub fn open(target: &Target) -> io::Result<()> { + // Launch the installed wrapper as the user. GParted owns elevation; the + // treemap must never run as root just to open a partition editor. + output(Command::new(&target.editor).arg(&target.disk)).map(|_| ()) +} + +fn output(command: &mut Command) -> io::Result { + let result = command.stdin(Stdio::null()).output()?; + if !result.status.success() { + let detail = String::from_utf8_lossy(&result.stderr); + return Err(io::Error::other(format!( + "{} exited with {}. {}", + command.get_program().to_string_lossy(), + result.status, + detail.trim().chars().take(500).collect::() + ))); + } + String::from_utf8(result.stdout).map_err(io::Error::other) +} + +fn device_path(mount: &str) -> io::Result { + let mounts: Mounts = + serde_json::from_str(mount).map_err(io::Error::other)?; + if let [filesystem] = mounts.filesystems.as_slice() + && filesystem.source.starts_with("/dev") + { + return Ok(filesystem.source.clone()); + } + Err(io::Error::other( + "Cannot identify this mount's block device.", + )) +} + +#[derive(Debug, Deserialize)] +struct Mounts { + filesystems: Vec, +} + +#[derive(Debug, Deserialize)] +struct Filesystem { + source: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct Topology { + blockdevices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Device { + path: PathBuf, + #[serde(rename = "type")] + kind: String, + #[serde(default)] + children: Vec, +} + +fn physical_disk(json: &str) -> io::Result<(PathBuf, Option)> { + let topology: Topology = + serde_json::from_str(json).map_err(io::Error::other)?; + let [device] = topology.blockdevices.as_slice() else { + return Err(unsupported()); + }; + let mut device = device; + let mut partition = None; + loop { + // Only an unambiguous chain through partitions and LUKS is supported. + // Picking one member of LVM, RAID or a multipath device is misleading. + match (device.kind.as_str(), device.children.as_slice()) { + ("disk", []) if device.path.starts_with("/dev") => { + return Ok((device.path.clone(), partition)); + } + ("part", [parent]) if partition.is_none() => { + partition = Some(device.path.clone()); + device = parent; + } + ("crypt", [parent]) => device = parent, + _ => return Err(unsupported()), + } + } +} + +fn unsupported() -> io::Error { + io::Error::other( + "This volume does not resolve to one supported physical disk. \ + Open your storage manager separately for LVM, RAID or virtual disks.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_a_partition_and_a_luks_mapping_to_the_whole_disk() { + for (leaf, expected_disk, expected_partition) in [ + ( + r#"{"path":"/dev/nvme0n1p5","type":"part","children":[ + {"path":"/dev/nvme0n1","type":"disk"}]}"#, + "/dev/nvme0n1", + Some("/dev/nvme0n1p5"), + ), + ( + r#"{"path":"/dev/mapper/root","type":"crypt","children":[ + {"path":"/dev/nvme0n1p5","type":"part","children":[ + {"path":"/dev/nvme0n1","type":"disk"}]}]}"#, + "/dev/nvme0n1", + Some("/dev/nvme0n1p5"), + ), + (r#"{"path":"/dev/sda","type":"disk"}"#, "/dev/sda", None), + ] { + let json = format!(r#"{{"blockdevices":[{leaf}]}}"#); + let (disk, partition) = + physical_disk(&json).expect("one physical disk"); + assert_eq!(disk, Path::new(expected_disk)); + assert_eq!(partition.as_deref(), expected_partition.map(Path::new)); + } + } + + #[test] + fn rejects_ambiguous_and_unsupported_storage() { + for json in [ + r#"{"blockdevices":[]}"#, + r#"{"blockdevices":[{"path":"/dev/a","type":"disk"}, + {"path":"/dev/b","type":"disk"}]}"#, + r#"{"blockdevices":[{"path":"/dev/vg/root","type":"lvm", + "children":[{"path":"/dev/sda","type":"disk"}]}]}"#, + r#"{"blockdevices":[{"path":"/dev/md0","type":"raid1", + "children":[{"path":"/dev/sda","type":"disk"}, + {"path":"/dev/sdb","type":"disk"}]}]}"#, + r#"{"blockdevices":[{"path":"/dev/loop0","type":"loop"}]}"#, + r#"{"blockdevices":[{"path":"/dev/sda1","type":"part"}]}"#, + r#"{"blockdevices":[{"path":"/tmp/disk","type":"disk"}]}"#, + "not json", + ] { + assert!(physical_disk(json).is_err(), "{json}"); + } + } + + #[test] + fn mount_source_requires_one_block_device_and_preserves_whitespace() { + for source in ["/dev/mapper/omarchy_root", "/dev/mapper/my disk"] { + let mount = + format!(r#"{{"filesystems":[{{"source":"{source}"}}]}}"#); + assert_eq!(device_path(&mount).unwrap(), Path::new(source)); + } + for invalid in [ + "not json", + r#"{"filesystems":[]}"#, + r#"{"filesystems":[{"source":"tmpfs"}]}"#, + r#"{"filesystems":[{"source":"server:/share"}]}"#, + r#"{"filesystems":[{"source":"/dev/sda1"},{"source":"/dev/sdb1"}]}"#, + ] { + assert!(device_path(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn passes_the_disk_as_one_argument_and_reports_editor_failure() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::TempDir::new().unwrap(); + let editor = temp.path().join("editor"); + std::fs::write( + &editor, + "#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = '/dev/disk with spaces' ]\n", + ) + .unwrap(); + std::fs::set_permissions( + &editor, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let target = Target { + disk: PathBuf::from("/dev/disk with spaces"), + partition: None, + editor, + }; + open(&target).expect("disk is passed literally"); + std::fs::write(&target.editor, "#!/bin/sh\necho denied >&2\nexit 1\n") + .unwrap(); + assert!(open(&target).unwrap_err().to_string().contains("denied")); + } +}