Skip to main content

max / alloy

Give alloy disk a partitioning surface Create, delete, format and resize, on the volume tab rather than behind a tab of their own: one fewer place to look, and they act on the row already selected. `disk.rs` had no write path of any kind before this beyond mount, unmount and power-off. THE REFUSAL IS DRIVE-WIDE, and that is the only interesting decision here. SYSTEM_MOUNTS already refuses to unmount the running system, and reusing it at volume level would have been wrong in a way that is easy to miss: the ESP on the boot disk is usually not mounted, /boot may not be either, and a spare partition beside them is idle by every test that check applies. Delete it and the machine still boots. Delete its neighbour and it does not. So one system mount anywhere on a disk takes the whole disk out of reach, the keys render dimmed with the reason rather than erroring on the press, and it is a refusal rather than a harder confirmation because there is no legitimate use of the console to repartition the disk it is running from. BUSCTL, NOT UDISKSCTL. The operations do not exist in udisksctl: its whole verb set is mount, unmount, unlock, lock, loop-setup, loop-delete, power-off, smart-simulate. They live on the udisks2 D-Bus interfaces, so they go over `busctl call`, which keeps them argv and keeps them in the command log with everything else. The alternative was sfdisk under run0, which would turn the verb that exists so a USB stick works into one that asks for a password. All four signatures were checked by introspection against a live udisks2 rather than read off documentation. Offsets are not computed here. `CreatePartition` is asked for a shape and udisks decides where it lands; working the gaps out from `lsblk -o START` would mean holding a second model of the partition table that can disagree with the one about to be written, and being wrong there means placing a partition over something. Two things found on the way: `Invocation::display` did not quote empty arguments, so the two empty strings CreatePartition takes rendered as runs of spaces and the pasted line carried six arguments instead of eight. Braces are quoted now too, since `{sv}` is a signature to busctl and a brace expansion to a shell. A confirmation is answered by a person, so `confirmed` now re-reads the disks and re-runs the refusals rather than trusting the copy armed when the prompt went up. The volume can have been mounted from another terminal or unplugged while it was on screen. 35 tests over the pane, up from 22. The suite must never execute one of these commands, so the re-check test swaps the backend rather than mutating state the refresh would overwrite; the first draft reached `run()` and sent a real Delete at this machine's /dev/sdb1, which existed only in the fixture.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 20:13 UTC
Signed with PGP, not checked
Commit: d2cb2084c19720f8ef71c5266db4b969c019c189
Parent: 2249522
3 files changed, +447 insertions, -4 deletions
M docs/CONSOLE.md +35 -1
@@ -22,7 +22,7 @@
22 22 alloy display # outputs: swaymsg front, scale and enable [shipped]
23 23 alloy pkg install # what is layered, and where a package belongs
24 24 alloy pkg box # containers: the isolation dial [shipped]
25 - alloy disk # attached storage: lsblk front, udisks acts [shipped]
25 + alloy disk # storage: lsblk front, udisks mounts and partitions [shipped]
26 26 alloy update # system updates: rpm-ostree front
27 27 alloy sync # syncthing front (see CONTINUITY.md) [shipped]
28 28 alloy setup # the first-boot offer: mesh and sync [shipped]
@@ -136,6 +136,40 @@
136 136 waits on the ladder in wiki note `alloy-privilege`. Nothing in the console escalates
137 137 anything today.
138 138
139 + `alloy disk` is the one pane that rewrites a partition table, and the only place
140 + in the console where a keypress can destroy data that was not already being
141 + overwritten by something the user asked for by name. Four operations sit on the
142 + volume tab beside mount, unmount and eject: `n` adds a partition, `d` deletes
143 + one, `f` writes a filesystem, `z` resizes. Three constraints hold them:
144 +
145 + - **The disk the system boots from is refused, not confirmed.** The check is
146 + drive-wide rather than volume-wide, which is the part worth reading twice: the
147 + ESP is usually unmounted and a spare partition beside it is idle by every test
148 + the mount guard applies, so a volume-level check would offer to delete a
149 + partition whose removal stops the machine booting. One system mount anywhere
150 + on a disk takes that whole disk out of reach, and the keys render dimmed with
151 + the reason rather than erroring when pressed.
152 + - **Every partition write confirms, and the confirm carries the facts.** `disk`'s
153 + standing rule is that a confirmation appears only where a mistake costs
154 + something, so that agreeing does not become reflex. Every partition write
155 + costs something, so the rule is restated rather than reused: the prompt names
156 + the device, its size, its label and the filesystem about to be destroyed. One
157 + function builds that sentence, so four prompts cannot describe the same volume
158 + differently.
159 + - **Nothing escalates.** udisks2 answers a session user through polkit, which is
160 + the reason it was the right tool for mounting and is the reason it stays the
161 + right one here. The operations do not exist in `udisksctl` (its whole verb set
162 + is mount, unmount, unlock, lock, loop-setup, loop-delete, power-off,
163 + smart-simulate), so they go over D-Bus as `busctl call`, which keeps them argv
164 + and keeps them in the command log like everything else. The alternative was
165 + `sfdisk` under `run0`, which would turn the verb that exists so plugging in a
166 + stick works into one that asks for the root password.
167 +
168 + A confirmation is answered by a person, so the disks are re-read and the
169 + refusals re-run between the answer and the command. The volume can have been
170 + mounted from another terminal or unplugged entirely while the prompt was up, and
171 + the answer given was about the state on screen when it was raised.
172 +
139 173 Backend picks that differ from the sketch above: `net` fronts `nmcli` alone,
140 174 since Alloy is Fedora and NetworkManager is what is there. `audio` fronts
141 175 `pactl` rather than `wpctl`, because `pactl -f json` is a documented contract
@@ -226,6 +226,13 @@
226 226 /// quoted so the displayed line is copy-pasteable into a shell and means
227 227 /// the same thing there as it did here.
228 228 ///
229 + /// An empty argument is quoted for the same reason and is the case that
230 + /// found this: `busctl call ... CreatePartition 'ttssa{sv}' 0 0 '' '' 0`
231 + /// passes two empty strings, and rendered bare they collapse into runs of
232 + /// spaces. The pasted line then carries six arguments instead of eight,
233 + /// which is not the command that ran. A brace is quoted too, since `{sv}`
234 + /// is a D-Bus signature to busctl and a brace expansion to a shell.
235 + ///
229 236 /// A command with [`stdin`](Self::stdin) cannot round-trip that way, so it
230 237 /// says so rather than rendering a line that would do nothing if pasted.
231 238 /// The `#` marks commentary the way the mock backends already do, and the
@@ -235,7 +242,7 @@
235 242 let mut out = String::from(&self.program);
236 243 for arg in &self.args {
237 244 out.push(' ');
238 - if arg.contains(char::is_whitespace) {
245 + if arg.is_empty() || arg.contains(['{', '}']) || arg.contains(char::is_whitespace) {
239 246 out.push('\'');
240 247 out.push_str(arg);
241 248 out.push('\'');
@@ -20,6 +20,28 @@
20 20 //! bug gets written. A user who wants to make an install drive wants
21 21 //! `alloy image`, and the empty state says so rather than leaving them to guess.
22 22 //!
23 + //! # What it does do, as of 2026-08-06: partitions
24 + //!
25 + //! Create, delete, format and resize, on the volume tab rather than behind a
26 + //! tab of their own. One fewer place to look, and the operations act on the
27 + //! volume already selected there.
28 + //!
29 + //! Three rules hold this surface, and the first is the one the rest rest on:
30 + //!
31 + //! 1. **The disk the running system boots from is refused outright**, and the
32 + //! check is drive-wide. See [`DiskView::drive_is_system`] for why a
33 + //! volume-level check is not enough, which is the least obvious thing in
34 + //! this file.
35 + //! 2. **Every partition write confirms, naming what is on the volume.** Not the
36 + //! command that would run and not "are you sure": the size, the label and
37 + //! the filesystem about to be destroyed. [`describe_loss`] is that sentence,
38 + //! in one place so four prompts cannot drift.
39 + //! 3. **Still nothing escalates.** The operations are not in `udisksctl`, whose
40 + //! verbs are mount, unmount, unlock, lock, loop-setup, loop-delete,
41 + //! power-off and smart-simulate. They are on the udisks2 D-Bus interfaces,
42 + //! reached with `busctl call` so they stay argv and stay in the command log.
43 + //! See [`udisks_call`].
44 + //!
23 45 //! # Two tools, one screen
24 46 //!
25 47 //! Reading is `lsblk`, acting is `udisksctl`, and they are detected separately
@@ -59,13 +81,15 @@
59 81
60 82 use alloy_tui::keys::Action;
61 83 use alloy_tui::{
62 - AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
63 - hint, text, unavailable,
84 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, TextField,
85 + Theme, binding, hint, text, unavailable,
64 86 };
65 87 use ratatui::Frame;
66 88 use ratatui::crossterm::event::{KeyCode, KeyEvent};
67 89 use ratatui::layout::{Constraint, Layout, Rect};
90 + use ratatui::style::{Modifier, Style};
68 91 use ratatui::text::{Line, Span};
92 + use ratatui::widgets::{Paragraph, Wrap};
69 93
70 94 use crate::cli::{CommandLog, Invocation};
71 95 use crate::install::format_size;
@@ -126,6 +150,14 @@
126 150 pub label: Option<String>,
127 151 pub mountpoint: Option<String>,
128 152 pub read_only: bool,
153 + /// lsblk's `TYPE`, kept verbatim rather than reduced to a bool.
154 + ///
155 + /// The partitioning surface needs `part` specifically, and the obvious
156 + /// shortcut (`kind != "disk"`) is wrong: a LUKS mapping is `crypt`, sits
157 + /// under a partition, and is not one. Deleting the thing a `crypt` row
158 + /// points at is a different operation from deleting a partition, so the
159 + /// distinction has to survive parsing.
160 + pub kind: String,
129 161 pub drive: Drive,
130 162 }
131 163
@@ -148,6 +180,18 @@
148 180 NotMounted,
149 181 /// Part of the running system.
150 182 System,
183 + /// On the disk the running system is installed to. Wider than
184 + /// [`Self::System`], which is about one volume: this refuses a partition
185 + /// that is itself idle because a sibling on the same disk is not.
186 + SystemDisk,
187 + /// Not a partition, so there is no partition to delete or resize. A whole
188 + /// disk carrying a filesystem directly, or a LUKS mapping.
189 + NotAPartition,
190 + /// Still mounted. Editing a partition under a live filesystem is how a
191 + /// mounted tree ends up pointing at bytes that moved.
192 + MountedForEdit,
193 + /// The device is read-only, so nothing can be written to it.
194 + ReadOnly,
151 195 }
152 196
153 197 impl Blocked {
@@ -159,8 +203,22 @@
159 203 Self::Mounted => "is already mounted",
160 204 Self::NotMounted => "is not mounted",
161 205 Self::System => "holds part of the running system",
206 + Self::SystemDisk => "is on the disk the running system boots from",
207 + Self::NotAPartition => "is not a partition",
208 + Self::MountedForEdit => "is mounted; unmount it first",
209 + Self::ReadOnly => "is read-only",
162 210 }
163 211 }
212 +
213 + /// Whether this refusal is absolute rather than a state the user can clear.
214 + ///
215 + /// The distinction is the whole reason the partitioning surface is safe:
216 + /// `MountedForEdit` says do something first, and [`Self::SystemDisk`] says
217 + /// this will never be offered. A refusal the user can argue with by
218 + /// pressing the key again is not a guard.
219 + pub(crate) const fn absolute(self) -> bool {
220 + matches!(self, Self::SystemDisk)
221 + }
164 222 }
165 223
166 224 impl Volume {
@@ -204,6 +262,46 @@
204 262 }
205 263 }
206 264
265 + /// Whether this row is a partition in the partition table, as opposed to a
266 + /// whole disk carrying a filesystem or a LUKS mapping sitting on one.
267 + pub(crate) fn is_partition(&self) -> bool {
268 + self.kind == "part"
269 + }
270 +
271 + /// Why the partition under this row cannot be deleted or resized, ignoring
272 + /// the disk-level check the view adds on top.
273 + ///
274 + /// Ordered most-permanent first, so the reason a user is given is the one
275 + /// they cannot do anything about rather than the one they can. A LUKS
276 + /// mapping that is also mounted should say it is not a partition, because
277 + /// unmounting it will not make it into one.
278 + pub(crate) fn edit_blocker(&self) -> Option<Blocked> {
279 + if self.is_system() {
280 + Some(Blocked::System)
281 + } else if !self.is_partition() {
282 + Some(Blocked::NotAPartition)
283 + } else if self.read_only {
284 + Some(Blocked::ReadOnly)
285 + } else if self.mountpoint.is_some() {
286 + Some(Blocked::MountedForEdit)
287 + } else {
288 + None
289 + }
290 + }
291 +
292 + /// Why this volume cannot be formatted.
293 + ///
294 + /// Looser than [`Self::edit_blocker`] by exactly one rule: a whole disk can
295 + /// be formatted. Writing a filesystem straight onto `/dev/sdb` is what a
296 + /// user who wants one big volume with no partition table is asking for, and
297 + /// it is what a stick arrives from the factory as.
298 + pub(crate) fn format_blocker(&self) -> Option<Blocked> {
299 + match self.edit_blocker() {
300 + Some(Blocked::NotAPartition) if self.kind == "disk" => None,
301 + other => other,
302 + }
303 + }
304 +
207 305 fn fstype_or_dash(&self) -> &str {
208 306 self.fstype.as_deref().unwrap_or("-")
209 307 }
@@ -246,6 +344,87 @@
246 344 fn eject(&self, _volume: &Volume) -> Option<Invocation> {
247 345 None
248 346 }
347 +
348 + /// Add a partition to the drive this volume sits on.
349 + ///
350 + /// `size` of 0 means "as large as the free space allows", which is udisks'
351 + /// own convention rather than one invented here.
352 + fn create_partition(&self, _drive: &Drive, _size: u64) -> Option<Invocation> {
353 + None
354 + }
355 +
356 + fn delete_partition(&self, _volume: &Volume) -> Option<Invocation> {
357 + None
358 + }
359 +
360 + fn format(&self, _volume: &Volume, _fstype: &str) -> Option<Invocation> {
361 + None
362 + }
363 +
364 + fn resize_partition(&self, _volume: &Volume, _size: u64) -> Option<Invocation> {
365 + None
366 + }
367 + }
368 +
369 + /// Filesystems the format action offers.
370 + ///
371 + /// Every one of these has its `mkfs` in the image, checked against the built
372 + /// rootfs on 2026-08-06. Offering a type whose tool is absent would fail inside
373 + /// udisks with a message about a helper rather than about the choice the user
374 + /// made, which is the shape of defect the fontconfig emoji alias was.
375 + ///
376 + /// Ordered by what a person formatting a removable drive actually wants:
377 + /// `exfat` for a large stick that has to be readable elsewhere, `vfat` for a
378 + /// small one and for an ESP, `ext4` for a Linux-only disk. `btrfs` and `xfs`
379 + /// follow for whole-disk use. `ntfs` is last and is here for interoperability
380 + /// rather than as a recommendation.
381 + pub(crate) const FILESYSTEMS: [&str; 6] = ["exfat", "vfat", "ext4", "btrfs", "xfs", "ntfs"];
382 +
383 + /// The udisks2 object path for a kernel device name.
384 + ///
385 + /// udisks escapes anything outside `[A-Za-z0-9]` as `_` followed by the byte in
386 + /// hex, so `dm-0` is `dm_2d0`. Every name this console sees today (`sdb1`,
387 + /// `nvme0n1p3`) passes through unchanged, which is exactly why the escaping is
388 + /// implemented rather than assumed away: the first device that needs it would
389 + /// otherwise produce a path that silently addresses nothing.
390 + fn udisks_path(name: &str) -> String {
391 + let mut escaped = String::with_capacity(name.len());
392 + for byte in name.bytes() {
393 + if byte.is_ascii_alphanumeric() {
394 + escaped.push(byte as char);
395 + } else {
396 + use std::fmt::Write as _;
397 + let _ = write!(escaped, "_{byte:02x}");
398 + }
399 + }
400 + format!("/org/freedesktop/UDisks2/block_devices/{escaped}")
401 + }
402 +
403 + /// A `busctl call` against udisks2, as argv.
404 + ///
405 + /// `busctl` rather than `udisksctl`, because udisksctl has no partition verbs
406 + /// at all: its whole command set is mount, unmount, unlock, lock, loop-setup,
407 + /// loop-delete, power-off and smart-simulate. The operations this surface needs
408 + /// exist only on the D-Bus interfaces, so the choice is between calling them
409 + /// and reaching for `sfdisk` under `run0`. Calling them keeps the property the
410 + /// verb was built on: udisks answers a session user through polkit, and nothing
411 + /// here escalates.
412 + ///
413 + /// `busctl` and not `gdbus`: both are in the image, and busctl is systemd's,
414 + /// which is already a hard dependency, while gdbus arrives with glib as a
415 + /// transitive one.
416 + fn udisks_call(name: &str, interface: &str, method: &str, args: &[&str]) -> Invocation {
417 + let mut invocation = Invocation::new("busctl").args([
418 + "call",
419 + "org.freedesktop.UDisks2",
420 + &udisks_path(name),
421 + &format!("org.freedesktop.UDisks2.{interface}"),
422 + method,
423 + ]);
424 + for arg in args {
425 + invocation = invocation.arg(*arg);
426 + }
427 + invocation
249 428 }
250 429
251 430 /// Pick a backend: the real one when `lsblk` answers, the mock otherwise.
@@ -317,6 +496,50 @@
317 496 Invocation::new("udisksctl").args(["power-off", "-b", volume.drive.path.as_str()])
318 497 })
319 498 }
499 +
500 + /// `offset` 0 with a size lets udisks place the partition in the first free
501 + /// region large enough; `size` 0 as well means the largest free region,
502 + /// whole. Neither number is computed here on purpose.
503 + ///
504 + /// The console could read partition starts out of `lsblk -o START` and work
505 + /// out the gaps itself, and then it would hold a second model of the
506 + /// partition table that can disagree with the one udisks is about to act
507 + /// on. Alignment, the GPT tail, and whatever an extended partition is doing
508 + /// are all places that model would be subtly wrong, and being wrong here
509 + /// means placing a partition over something. Ask for the shape and let the
510 + /// tool that owns the table decide where it lands.
511 + fn create_partition(&self, drive: &Drive, size: u64) -> Option<Invocation> {
512 + let name = drive.path.rsplit('/').next()?;
513 + self.udisks.then(|| {
514 + udisks_call(
515 + name,
516 + "PartitionTable",
517 + "CreatePartition",
518 + &["ttssa{sv}", "0", &size.to_string(), "", "", "0"],
519 + )
520 + })
521 + }
522 +
523 + fn delete_partition(&self, volume: &Volume) -> Option<Invocation> {
524 + self.udisks
525 + .then(|| udisks_call(&volume.name, "Partition", "Delete", &["a{sv}", "0"]))
526 + }
527 +
528 + fn format(&self, volume: &Volume, fstype: &str) -> Option<Invocation> {
529 + self.udisks
530 + .then(|| udisks_call(&volume.name, "Block", "Format", &["sa{sv}", fstype, "0"]))
531 + }
532 +
533 + fn resize_partition(&self, volume: &Volume, size: u64) -> Option<Invocation> {
534 + self.udisks.then(|| {
535 + udisks_call(
536 + &volume.name,
537 + "Partition",
538 + "Resize",
539 + &["ta{sv}", &size.to_string(), "0"],
540 + )
541 + })
542 + }
320 543 }
321 544
322 545 /// Fixed sample volumes, for machines without lsblk.
@@ -350,6 +573,7 @@
350 573 label: Some("ALLOY".to_string()),
351 574 mountpoint: None,
352 575 read_only: false,
576 + kind: "part".to_string(),
353 577 drive: stick,
354 578 },
355 579 Volume {
@@ -360,6 +584,7 @@
360 584 label: None,
361 585 mountpoint: Some("/".to_string()),
362 586 read_only: false,
587 + kind: "part".to_string(),
363 588 drive: internal,
364 589 },
365 590 ])
@@ -481,6 +706,7 @@
481 706 label: meaningful(device.label.clone()),
482 707 mountpoint: first_mountpoint(device),
483 708 read_only: device.ro,
709 + kind: device.kind.clone(),
484 710 drive: drive.clone(),
485 711 });
486 712 }
@@ -532,6 +758,112 @@
532 758 enum PendingAction {
533 759 /// Eject a drive that still has something mounted on it.
534 760 Eject(Volume),
761 + /// Add a partition to a drive. Carries the drive rather than the volume the
762 + /// cursor was on, because the operation is the table's and not the row's.
763 + Create {
764 + drive: Drive,
765 + size: u64,
766 + },
767 + Delete(Volume),
768 + Format {
769 + volume: Volume,
770 + fstype: String,
771 + },
772 + Resize {
773 + volume: Volume,
774 + size: u64,
775 + },
776 + }
777 +
778 + /// A value being typed or picked before its action can be confirmed.
779 + ///
780 + /// Two of the four partition operations need a number and one needs a choice
781 + /// from a list, so the surface is a small state machine rather than four keys
782 + /// that act immediately. The machine is deliberately shallow: one step, then
783 + /// the confirm, then the command. Escape leaves at any point and nothing has
784 + /// happened.
785 + enum Editing {
786 + /// Size for a new partition on this drive. Empty means fill the free space.
787 + CreateSize { drive: Drive, field: TextField },
788 + /// Which filesystem to write onto this volume.
789 + FormatType { volume: Volume, choice: Cursor },
790 + /// New size for this partition.
791 + ResizeSize { volume: Volume, field: TextField },
792 + }
793 +
794 + impl Editing {
795 + /// The line above the field, naming what is being asked and what it acts
796 + /// on. A prompt that says only "size:" leaves the user to remember which
797 + /// row they pressed a key on.
798 + fn prompt(&self) -> String {
799 + match self {
800 + Self::CreateSize { drive, .. } => format!(
801 + "New partition on {}. Size (blank fills the free space), Enter to confirm, Esc to cancel:",
802 + drive.path
803 + ),
804 + Self::FormatType { volume, .. } => format!(
805 + "Format {}. j/k to choose, Enter to confirm, Esc to cancel:",
806 + volume.path
807 + ),
808 + Self::ResizeSize { volume, .. } => format!(
809 + "Resize {} from {}. New size, Enter to confirm, Esc to cancel:",
810 + volume.path,
811 + format_size(volume.size)
812 + ),
813 + }
814 + }
815 + }
816 +
817 + /// Parse a size the way a person writes one.
818 + ///
819 + /// Decimal units by default, matching [`format_size`]: this screen prints
820 + /// "16.0 GB" for a stick and a user typing "16GB" back at it must mean the same
821 + /// number. Binary units are accepted where they are spelled out (`GiB`), since
822 + /// anyone who writes the `i` knows which one they want.
823 + ///
824 + /// A bare number is bytes. Not megabytes, which some partitioners assume: a
825 + /// silent factor of a million between what was typed and what is created is
826 + /// exactly the kind of thing this console must not do.
827 + pub(crate) fn parse_size(raw: &str) -> Result<u64> {
828 + const SCALES: [(&str, u64); 9] = [
829 + ("kb", 1_000),
830 + ("mb", 1_000_000),
831 + ("gb", 1_000_000_000),
832 + ("tb", 1_000_000_000_000),
833 + ("kib", 1 << 10),
834 + ("mib", 1 << 20),
835 + ("gib", 1 << 30),
836 + ("tib", 1 << 40),
837 + ("b", 1),
838 + ];
839 +
840 + let trimmed = raw.trim().to_ascii_lowercase();
841 + if trimmed.is_empty() {
842 + anyhow::bail!("no size given");
843 + }
844 +
845 + // Longest suffix first, so "gib" is not read as "b" with "gi" left over.
846 + let mut candidates: Vec<(&str, u64)> = SCALES.to_vec();
847 + candidates.sort_by_key(|(unit, _)| std::cmp::Reverse(unit.len()));
848 +
849 + let (number, scale) = candidates
850 + .iter()
851 + .find_map(|(unit, scale)| trimmed.strip_suffix(unit).map(|rest| (rest, *scale)))
852 + .unwrap_or((trimmed.as_str(), 1));
853 +
854 + let number: f64 = number
855 + .trim()
856 + .parse()
857 + .with_context(|| format!("not a size: {}", raw.trim()))?;
858 + if !number.is_finite() || number <= 0.0 {
859 + anyhow::bail!("not a size: {}", raw.trim());
860 + }
861 +
862 + let bytes = number * scale as f64;
863 + if bytes >= u64::MAX as f64 {
864 + anyhow::bail!("size is larger than any disk: {}", raw.trim());
865 + }
866 + Ok(bytes as u64)
535 867 }
536 868
537 869 /// The `alloy disk` screen.
@@ -545,6 +877,8 @@
545 877 all_cursor: Cursor,
546 878 error: Option<String>,
547 879 pending_action: Option<PendingAction>,
880 + /// The value being collected before a partition action can be confirmed.
881 + editing: Option<Editing>,
548 882 }
549 883
550 884 impl DiskView {
@@ -560,6 +894,7 @@
560 894 all_cursor: Cursor::new(),
561 895 error: None,
562 896 pending_action: None,
897 + editing: None,
563 898 };
564 899 view.refresh(log);
565 900 view
@@ -639,6 +974,245 @@
639 974 self.backend.mount(&mock_probe_volume()).is_some()
640 975 }
641 976
977 + /// Whether the running system lives on this drive.
978 + ///
979 + /// The check the whole partitioning surface rests on, and it is deliberately
980 + /// drive-wide rather than volume-wide. `SYSTEM_MOUNTS` already refuses to
981 + /// unmount `/`, and that is enough for unmounting, where the worst case is
982 + /// an error from udisks. It is not enough here: the ESP on the boot disk is
983 + /// usually not mounted, `/boot` may not be either, and a spare partition
984 + /// beside them is idle by every test the volume-level check applies. Delete
985 + /// it and the machine still boots; delete the one next to it and it does
986 + /// not. So the refusal attaches to the disk, and one system mount anywhere
987 + /// on it takes the whole disk out of reach.
988 + ///
989 + /// It is a refusal and not a confirmation, per the ruling on this task:
990 + /// there is no legitimate use of the console to repartition the disk it is
991 + /// running from, so offering it behind a prompt would only be offering a
992 + /// way to get it wrong.
993 + fn drive_is_system(&self, drive: &Drive) -> bool {
994 + self.volumes
995 + .iter()
996 + .any(|volume| volume.drive.path == drive.path && volume.is_system())
997 + }
998 +
999 + /// The blocker for a partition edit, disk check included.
1000 + fn edit_blocker(&self, volume: &Volume) -> Option<Blocked> {
1001 + if self.drive_is_system(&volume.drive) {
1002 + return Some(Blocked::SystemDisk);
1003 + }
1004 + volume.edit_blocker()
1005 + }
1006 +
1007 + /// The refusal that applies to every partition key on the selected row, or
1008 + /// `None` if the row can be worked on.
1009 + ///
1010 + /// Only absolute refusals count here. A mounted partition still gets its
1011 + /// keys rendered as available, because pressing one and being told to
1012 + /// unmount is how a user learns what to do next; a partition on the boot
1013 + /// disk gets them dimmed with the reason, because there is no next step.
1014 + /// That is what [`Blocked::absolute`] is for.
1015 + fn partition_blocker(&self) -> Option<Blocked> {
1016 + let volume = self.selected()?;
1017 + self.edit_blocker(&volume).filter(|b| b.absolute())
1018 + }
1019 +
1020 + fn refuse(&mut self, volume: &Volume, blocked: Blocked) {
1021 + self.error = Some(format!("{} {}", volume.path, blocked.reason()));
1022 + }
1023 +
1024 + /// Start a new partition on the selected row's drive.
1025 + fn create_partition(&mut self) {
1026 + let Some(volume) = self.selected() else {
1027 + self.error = Some("nothing selected".to_string());
1028 + return;
1029 + };
1030 + if self.drive_is_system(&volume.drive) {
1031 + self.error = Some(format!(
1032 + "{} {}",
1033 + volume.drive.path,
1034 + Blocked::SystemDisk.reason()
1035 + ));
1036 + return;
1037 + }
1038 + if self.backend.create_partition(&volume.drive, 0).is_none() {
1039 + self.error = Some(no_udisks(self.backend.name(), &volume));
1040 + return;
1041 + }
1042 + self.editing = Some(Editing::CreateSize {
1043 + drive: volume.drive.clone(),
Lines truncated