| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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(),
|