hexz_cli/cmd/vm/mount.rs
1//! Mount Hexz snapshots as FUSE filesystems or NBD block devices.
2//!
3//! This command exposes Hexz snapshots to the host operating system by mounting
4//! them as either FUSE filesystems (default) or NBD (Network Block Device) devices.
5//! The mount provides read-only or read-write access with optional overlay support
6//! for copy-on-write semantics, cache configuration, and permission mapping.
7//!
8//! # Mount Mechanisms
9//!
10//! ## FUSE (Filesystem in Userspace) - Default
11//!
12//! **Characteristics:**
13//! - No root/sudo required (uses user-space FUSE)
14//! - Exposes `disk` and `memory` files at mount point
15//! - Supports overlay for read-write mounts
16//! - Can run as daemon for background mounting
17//!
18//! **File Structure:**
19//! ```text
20//! /mnt/snapshot/
21//! ├── disk # Disk image (raw format)
22//! └── memory # Memory dump (if present in snapshot)
23//! ```
24//!
25//! **Use Cases:**
26//! - Mount disk images for file extraction without VM
27//! - Read-write mounts with overlay for ephemeral changes
28//! - Development and debugging (inspect snapshot contents)
29//! - Backup and archival access
30//!
31//! ## NBD (Network Block Device) - Optional
32//!
33//! **Characteristics:**
34//! - Requires root/sudo and `nbd` kernel module
35//! - Creates `/dev/nbdN` block device
36//! - Supports standard filesystem mounting (ext4, xfs, etc.)
37//! - Better performance for large sequential I/O
38//!
39//! **Workflow:**
40//! 1. Start internal NBD server on localhost
41//! 2. Connect `nbd-client` to server
42//! 3. Mount resulting `/dev/nbdN` device
43//! 4. Wait for Ctrl+C, then cleanup
44//!
45//! **Use Cases:**
46//! - Native filesystem mounting without FUSE overhead
47//! - Integration with existing block device tooling
48//! - Performance-critical applications
49//!
50//! # Overlay Behavior
51//!
52//! When mounted read-write (`--rw`), an overlay file tracks modifications:
53//!
54//! **Overlay Mechanism:**
55//! - **Reads**: Served from base snapshot (fast, cached)
56//! - **Writes**: Captured in overlay file at 4 KiB granularity
57//! - **Metadata**: `.meta` file tracks modified block indices
58//!
59//! **Overlay Storage:**
60//! - Ephemeral (default): Temporary file deleted on unmount
61//! - Persistent (`--overlay <path>`): Saved for later commit
62//!
63//! **Commit Workflow:**
64//! ```bash
65//! # Mount with persistent overlay
66//! hexz mount snapshot.st /mnt --rw --overlay changes.overlay
67//!
68//! # Make changes inside /mnt
69//! # ...
70//!
71//! # Unmount
72//! hexz unmount /mnt
73//!
74//! # Commit changes to new snapshot
75//! hexz vm commit --base snapshot.st --overlay changes.overlay --output new.st
76//! ```
77//!
78//! # Cache Size Semantics
79//!
80//! The `--cache-size` parameter controls in-memory block caching:
81//!
82//! **Cache Behavior:**
83//! - Stores recently accessed compressed blocks in memory
84//! - LRU (Least Recently Used) eviction policy
85//! - Reduces decompression overhead for repeated reads
86//!
87//! **Size Guidelines:**
88//! - Default: No cache (every read decompresses from storage)
89//! - `--cache-size 256M`: 256 MB cache (good for development VMs)
90//! - `--cache-size 1G`: 1 GB cache (good for production workloads)
91//! - `--cache-size 4G`: 4 GB cache (maximum benefit for most workloads)
92//!
93//! **Performance Impact:**
94//! - Cache hit: ~10 GB/s (memcpy from cache)
95//! - Cache miss: ~500 MB/s (LZ4) or ~200 MB/s (Zstd)
96//! - Working set > cache size: Performance degrades to uncached speed
97//!
98//! # UID/GID Implications
99//!
100//! The `--uid` and `--gid` parameters control file ownership inside the mount:
101//!
102//! **Ownership Mapping:**
103//! - All files inside the mount appear owned by `uid:gid`
104//! - Does not modify actual data in snapshot (metadata-only)
105//! - Affects permission checks for file access
106//!
107//! **Common Values:**
108//! - `--uid 1000 --gid 1000`: Default user (typical for desktop Linux)
109//! - `--uid 0 --gid 0`: Root ownership (for system images)
110//! - Custom values: Match specific user requirements
111//!
112//! **Use Cases:**
113//! - Allow non-root users to access mounted disk images
114//! - Match ownership to container or VM user mappings
115//! - Control write permissions in read-write mounts
116//!
117//! # FUSE Integration Details
118//!
119//! The FUSE implementation uses the `fuser` crate to implement:
120//!
121//! **FUSE Operations:**
122//! - `lookup()`: Resolves `disk` and `memory` file entries
123//! - `getattr()`: Returns file metadata (size, permissions, ownership)
124//! - `read()`: Reads data from snapshot at specified offset
125//! - `write()`: Writes data to overlay (read-write mode only)
126//! - `release()`: Syncs overlay metadata on file close
127//!
128//! **Mount Options:**
129//! - `FSName=hexz`: Identifies mount in `/proc/mounts`
130//! - `DefaultPermissions`: Enables kernel permission checks
131//! - `RO` or `RW`: Read-only or read-write mode
132//!
133//! # Common Usage Patterns
134//!
135//! ```bash
136//! # Read-only FUSE mount
137//! hexz mount snapshot.st /mnt
138//!
139//! # Read-write mount with ephemeral overlay
140//! hexz mount snapshot.st /mnt --rw
141//!
142//! # Read-write mount with persistent overlay
143//! hexz mount snapshot.st /mnt --rw --overlay changes.overlay
144//!
145//! # Mount as daemon with cache
146//! hexz mount snapshot.st /mnt --daemon --cache-size 512M
147//!
148//! # NBD mount (requires root)
149//! sudo hexz mount snapshot.st /mnt --nbd
150//!
151//! # Custom ownership
152//! hexz mount snapshot.st /mnt --uid 1000 --gid 1000
153//! ```
154
155use anyhow::{Context, Result};
156use daemonize::Daemonize;
157use hexz_common::constants::DEFAULT_ZSTD_LEVEL;
158use hexz_core::File;
159use hexz_core::algo::compression::{Compressor, lz4::Lz4Compressor, zstd::ZstdCompressor};
160use hexz_core::algo::encryption::aes_gcm::AesGcmEncryptor;
161use hexz_core::format::header::{CompressionType, Header};
162use hexz_core::format::magic::HEADER_SIZE;
163use hexz_fuse::fuse::Hexz;
164use hexz_store::StorageBackend;
165use hexz_store::local::MmapBackend;
166use std::path::{Path, PathBuf};
167use std::process::Command;
168use std::sync::{
169 Arc,
170 atomic::{AtomicBool, Ordering},
171};
172
173/// Parses human-readable size strings into byte counts.
174///
175/// Supports common size suffixes: K/KB, M/MB, G/GB, T/TB (case-insensitive).
176/// Numbers can be integers or floating-point values.
177///
178/// # Arguments
179///
180/// * `s` - Size string (e.g., "256M", "1.5G", "512KB", "1024")
181///
182/// # Returns
183///
184/// Size in bytes as `usize`.
185///
186/// # Examples
187///
188/// ```text
189/// "256M" → 268435456
190/// "1.5G" → 1610612736
191/// "512KB" → 524288
192/// "1024" → 1024
193/// ```
194///
195/// # Errors
196///
197/// Returns an error if:
198/// - The numeric part cannot be parsed as `f64`
199/// - The suffix is not recognized (valid: k, kb, m, mb, g, gb, t, tb, or empty)
200pub(crate) fn parse_size(s: &str) -> Result<usize> {
201 let s = s.trim();
202 let (num, suffix) = if let Some(idx) = s.find(|c: char| !c.is_numeric() && c != '.') {
203 (&s[..idx], &s[idx..])
204 } else {
205 (s, "")
206 };
207
208 let n: f64 = num.parse()?;
209 let multiplier = match suffix.to_lowercase().as_str() {
210 "k" | "kb" => 1024.0,
211 "m" | "mb" => 1024.0 * 1024.0,
212 "g" | "gb" => 1024.0 * 1024.0 * 1024.0,
213 "t" | "tb" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
214 "" => 1.0,
215 _ => return Err(anyhow::anyhow!("Invalid size suffix: {}", suffix)),
216 };
217
218 Ok((n * multiplier) as usize)
219}
220
221/// Opens a Hexz snapshot and initializes decompression and decryption.
222///
223/// This helper function is shared by both FUSE and NBD code paths. It handles:
224/// - Reading and parsing the snapshot header
225/// - Prompting for password if snapshot is encrypted
226/// - Loading compression dictionary if present
227/// - Initializing the appropriate decompressor (LZ4 or Zstd)
228/// - Setting up decryption if required
229/// - Configuring block cache if cache size is specified
230///
231/// # Arguments
232///
233/// * `hexz_path` - Path to the `.st` snapshot file (can be relative or absolute)
234/// * `cache_size` - Optional cache size string (e.g., "256M", "1G")
235///
236/// # Returns
237///
238/// `Arc<File>` that can be shared across threads for concurrent access.
239///
240/// # Password Handling
241///
242/// If the snapshot header indicates encryption:
243/// - Prompts user for password via `rpassword::prompt_password()`
244/// - Derives encryption key using PBKDF2 with salt from header
245/// - Initializes AES-256-GCM decryptor
246///
247/// # Errors
248///
249/// Returns an error if:
250/// - Snapshot file cannot be opened or read
251/// - Header deserialization fails (corrupted snapshot)
252/// - Password is incorrect (decryption fails)
253/// - Dictionary cannot be loaded (corrupted dictionary region)
254/// - Cache size string is malformed
255fn open_snapshot(
256 hexz_path: &str,
257 cache_size: Option<String>,
258 prefetch: Option<u32>,
259) -> Result<Arc<File>> {
260 let abs_hexz_path = std::fs::canonicalize(hexz_path)
261 .context(format!("Failed to resolve snapshot path: {}", hexz_path))?;
262
263 // Pre-read header for password prompt
264 let (header, password) = {
265 let backend = MmapBackend::new(&abs_hexz_path)?;
266 let header_bytes = backend.read_exact(0, HEADER_SIZE)?;
267 let header: Header = bincode::deserialize(&header_bytes)?;
268
269 let password = if header.encryption.is_some() {
270 Some(rpassword::prompt_password("Enter encryption password: ")?)
271 } else {
272 None
273 };
274 (header, password)
275 };
276
277 let backend = Arc::new(MmapBackend::new(&abs_hexz_path)?);
278
279 let dictionary = if let (Some(offset), Some(length)) =
280 (header.dictionary_offset, header.dictionary_length)
281 {
282 Some(backend.read_exact(offset, length as usize)?.to_vec())
283 } else {
284 None
285 };
286
287 let compressor: Box<dyn Compressor> = match header.compression {
288 CompressionType::Lz4 => Box::new(Lz4Compressor::new()),
289 CompressionType::Zstd => Box::new(ZstdCompressor::new(DEFAULT_ZSTD_LEVEL, dictionary)),
290 };
291
292 let encryptor = if let (Some(params), Some(pass)) = (header.encryption, password) {
293 Some(Box::new(AesGcmEncryptor::new(
294 pass.as_bytes(),
295 ¶ms.salt,
296 params.iterations,
297 )?)
298 as Box<dyn hexz_core::algo::encryption::Encryptor>)
299 } else {
300 None
301 };
302
303 let cache_capacity = if let Some(s) = cache_size {
304 Some(parse_size(&s)?)
305 } else {
306 None
307 };
308
309 Ok(File::with_cache(
310 backend,
311 compressor,
312 encryptor,
313 cache_capacity,
314 prefetch,
315 )?)
316}
317
318/// Executes the mount command to expose a snapshot via FUSE or NBD.
319///
320/// Mounts a Hexz snapshot at the specified mountpoint using either FUSE
321/// (default) or NBD (if `--nbd` flag is set). Supports read-only and read-write
322/// modes, optional overlay for copy-on-write, caching, and daemon mode.
323///
324/// # Arguments
325///
326/// * `hexz_path` - Path to the `.st` snapshot file
327/// * `mountpoint` - Directory where snapshot will be mounted
328/// * `overlay` - Optional overlay file path for read-write mounts (persistent)
329/// * `daemon` - If true, daemonize the process and run in background
330/// * `rw` - If true, mount read-write with overlay; otherwise read-only
331/// * `cache_size` - Optional cache size (e.g., "256M", "1G")
332/// * `uid` - User ID for file ownership inside mount
333/// * `gid` - Group ID for file ownership inside mount
334/// * `nbd` - If true, use NBD instead of FUSE (requires root)
335///
336/// # FUSE Mode (Default)
337///
338/// Creates a FUSE filesystem at `mountpoint` with:
339/// - `disk` file containing disk image
340/// - `memory` file containing memory dump (if present in snapshot)
341///
342/// **Read-Only Mode:**
343/// - All writes are rejected with EROFS error
344/// - No overlay file created
345/// - Safe for concurrent mounts of same snapshot
346///
347/// **Read-Write Mode:**
348/// - Writes captured in overlay file (4 KiB granularity)
349/// - Metadata tracked in `.meta` file
350/// - Overlay can be persistent (specified path) or ephemeral (temp file)
351///
352/// **Daemon Mode:**
353/// - Process detaches and runs in background
354/// - Logs redirected to `/tmp/hexz.log` and `/tmp/hexz.err`
355/// - Working directory changed to `/`
356/// - Useful for long-running mounts
357///
358/// # NBD Mode (`--nbd`)
359///
360/// Creates an NBD block device and mounts it:
361/// 1. Starts internal NBD server on ephemeral port (localhost only)
362/// 2. Runs `nbd-client` to connect to server and create `/dev/nbdN`
363/// 3. Runs `mount` to mount the NBD device at `mountpoint`
364/// 4. Waits for Ctrl+C
365/// 5. Unmounts and cleans up NBD device
366///
367/// **Requirements:**
368/// - Root privileges (uses `sudo` for `nbd-client` and `mount`)
369/// - `nbd` kernel module loaded (`sudo modprobe nbd`)
370/// - `nbd-client` utility installed
371///
372/// **NBD Server:**
373/// - Binds to `127.0.0.1` (localhost only, not exposed to network)
374/// - Automatically selects free ephemeral port
375/// - Serves primary stream only (memory not exposed via NBD)
376///
377/// # Overlay File Paths
378///
379/// If `overlay` is specified:
380/// - Absolute path: Used as-is
381/// - Relative path: Resolved relative to current working directory
382/// - Path does not need to exist (will be created)
383///
384/// If `overlay` is None and `rw` is true:
385/// - Creates temporary file that is deleted on unmount
386/// - Useful for ephemeral changes (testing, development)
387///
388/// # Errors
389///
390/// Returns an error if:
391/// - Snapshot file cannot be opened
392/// - Mountpoint does not exist or is not a directory
393/// - FUSE mount fails (permissions, already mounted)
394/// - NBD server fails to start (port unavailable, feature not compiled)
395/// - NBD client/mount commands fail (not installed, wrong permissions)
396/// - Daemonization fails (resource limits, permissions)
397///
398/// # Examples
399///
400/// ```no_run
401/// use std::path::PathBuf;
402/// use hexz_cli::cmd::vm::mount;
403///
404/// // Read-only FUSE mount
405/// mount::run(
406/// "snapshot.hxz".to_string(),
407/// PathBuf::from("/mnt"),
408/// None,
409/// false, // not daemon
410/// false, // read-only
411/// None, // no cache
412/// 1000, // uid
413/// 1000, // gid
414/// false, // FUSE mode
415/// None, // no prefetch
416/// )?;
417///
418/// // Read-write mount with persistent overlay and prefetch
419/// mount::run(
420/// "snapshot.hxz".to_string(),
421/// PathBuf::from("/mnt"),
422/// Some(PathBuf::from("changes.overlay")),
423/// false,
424/// true, // read-write
425/// Some("512M".to_string()), // 512 MB cache
426/// 1000,
427/// 1000,
428/// false,
429/// Some(8), // prefetch 8 blocks ahead
430/// )?;
431/// # Ok::<(), anyhow::Error>(())
432/// ```
433#[allow(clippy::too_many_arguments)]
434pub fn run(
435 hexz_path: String,
436 mountpoint: PathBuf,
437 overlay: Option<PathBuf>,
438 daemon: bool,
439 rw: bool,
440 cache_size: Option<String>,
441 uid: u32,
442 gid: u32,
443 nbd: bool,
444 prefetch: Option<u32>,
445) -> Result<()> {
446 if nbd {
447 #[cfg(feature = "server")]
448 return run_nbd(hexz_path, mountpoint, cache_size);
449 #[cfg(not(feature = "server"))]
450 anyhow::bail!("NBD support requires the 'server' feature");
451 }
452
453 // --- FUSE Implementation ---
454
455 let abs_mountpoint = std::fs::canonicalize(&mountpoint)
456 .context(format!("Failed to resolve mountpoint: {:?}", mountpoint))?;
457
458 // FIX: Don't use canonicalize on the overlay path directly, as it fails if the file doesn't exist.
459 // Instead, resolve it relative to current dir if needed, or just pass it through.
460 // Hexz::new handles creation.
461 let abs_overlay_path = if let Some(p) = &overlay {
462 if p.is_absolute() {
463 Some(p.clone())
464 } else {
465 Some(std::env::current_dir()?.join(p))
466 }
467 } else {
468 None
469 };
470
471 // Open snapshot
472 let snap = open_snapshot(&hexz_path, cache_size, prefetch)?;
473
474 // Daemonize if requested
475 if daemon {
476 let log_dir = std::env::var("XDG_RUNTIME_DIR")
477 .or_else(|_| std::env::var("TMPDIR"))
478 .unwrap_or_else(|_| "/tmp".to_string());
479 let stdout = std::fs::File::create(format!("{}/hexz.log", log_dir))
480 .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
481 let stderr = std::fs::File::create(format!("{}/hexz.err", log_dir))
482 .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
483
484 Daemonize::new()
485 .working_directory("/")
486 .stdout(stdout)
487 .stderr(stderr)
488 .start()?;
489 }
490
491 // Handle RW overlay
492 let (_temp_file, final_overlay_path) = if let Some(p) = abs_overlay_path {
493 (None, Some(p))
494 } else if rw {
495 let t = tempfile::NamedTempFile::new()?;
496 let path = t.path().to_path_buf();
497 let meta = path.with_extension("meta");
498 std::fs::File::create(&meta)?;
499 (Some(t), Some(path))
500 } else {
501 (None, None)
502 };
503
504 let mut options = vec![
505 fuser::MountOption::FSName("hexz".to_string()),
506 fuser::MountOption::DefaultPermissions,
507 ];
508
509 if rw {
510 options.push(fuser::MountOption::RW);
511 } else {
512 options.push(fuser::MountOption::RO);
513 }
514
515 let fs = Hexz::new(snap, final_overlay_path.as_deref(), uid, gid)?;
516
517 if daemon {
518 eprintln!("Mounting at {:?} (daemonized)", abs_mountpoint);
519 }
520
521 fuser::mount2(fs, abs_mountpoint, &options)?;
522
523 Ok(())
524}
525
526#[cfg(feature = "server")]
527fn run_nbd(hexz_path: String, mountpoint: PathBuf, cache_size: Option<String>) -> Result<()> {
528 // 1. Check for sudo/root (NBD requires it)
529 let is_root = unsafe { libc::geteuid() == 0 };
530 if !is_root {
531 println!("Note: NBD mounting requires root privileges. You may be prompted for sudo.");
532 }
533
534 // 2. Open Snapshot
535 let snap = open_snapshot(&hexz_path, cache_size, None)?;
536
537 // 3. Find a free NBD device
538 let nbd_dev = find_free_nbd_device()?;
539 println!("Selected NBD device: {}", nbd_dev);
540
541 // 4. Start Server in background runtime
542 let rt = tokio::runtime::Runtime::new()?;
543
544 // Bind to ephemeral port
545 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
546 let port = listener.local_addr()?.port();
547 drop(listener); // Close so server can bind
548
549 println!("Starting internal NBD server on port {}...", port);
550
551 let snap_clone = snap.clone();
552 rt.spawn(async move {
553 if let Err(e) = hexz_server::serve_nbd(snap_clone, port, "127.0.0.1").await {
554 eprintln!("NBD Server error: {}", e);
555 }
556 });
557
558 // Give server a moment to start
559 std::thread::sleep(std::time::Duration::from_millis(200));
560
561 // 5. Connect Client
562 println!("Connecting NBD client...");
563 let status = Command::new("sudo")
564 .arg("nbd-client")
565 .arg("localhost")
566 .arg(port.to_string())
567 .arg(&nbd_dev)
568 .status()
569 .context("Failed to run nbd-client")?;
570
571 if !status.success() {
572 anyhow::bail!("nbd-client failed. Is the 'nbd' kernel module loaded?");
573 }
574
575 // 6. Mount
576 println!("Mounting {} to {:?}...", nbd_dev, mountpoint);
577 let status = Command::new("sudo")
578 .arg("mount")
579 .arg(&nbd_dev)
580 .arg(&mountpoint)
581 .status()
582 .context("Failed to run mount")?;
583
584 if !status.success() {
585 // Cleanup NBD if mount fails
586 let _ = Command::new("sudo")
587 .arg("nbd-client")
588 .arg("-d")
589 .arg(&nbd_dev)
590 .status();
591 anyhow::bail!("Mount command failed.");
592 }
593
594 println!("Successfully mounted via NBD.");
595 println!("Press Ctrl+C to unmount and cleanup.");
596
597 // 7. Wait for Ctrl+C
598 let running = Arc::new(AtomicBool::new(true));
599 let r = running.clone();
600
601 ctrlc::set_handler(move || {
602 r.store(false, Ordering::SeqCst);
603 })?;
604
605 while running.load(Ordering::SeqCst) {
606 std::thread::sleep(std::time::Duration::from_millis(100));
607 }
608
609 // 8. Cleanup
610 println!("\nCleaning up...");
611
612 let _ = Command::new("sudo").arg("umount").arg(&mountpoint).status();
613 let _ = Command::new("sudo")
614 .arg("nbd-client")
615 .arg("-d")
616 .arg(&nbd_dev)
617 .status();
618
619 Ok(())
620}
621
622#[cfg(feature = "server")]
623fn find_free_nbd_device() -> Result<String> {
624 // Simple heuristic: Try to find a device that isn't connected.
625 // We check /sys/class/block/nbd*/pid. If file exists and contains content, it's busy.
626 // Or simpler: /sys/class/block/nbd*/size is 0 for disconnected devices.
627
628 for i in 0..16 {
629 let dev = format!("/dev/nbd{}", i);
630 let sys_path = format!("/sys/class/block/nbd{}/size", i);
631
632 if Path::new(&sys_path).exists() {
633 let content = std::fs::read_to_string(&sys_path).unwrap_or_default();
634 if content.trim() == "0" {
635 return Ok(dev);
636 }
637 }
638 }
639 anyhow::bail!("No free /dev/nbd devices found. Try 'sudo modprobe nbd max_part=8'")
640}
641
642#[cfg(test)]
643mod tests {
644 use super::parse_size;
645
646 #[test]
647 fn test_parse_size_megabytes() {
648 assert_eq!(parse_size("256M").unwrap(), 268435456);
649 }
650
651 #[test]
652 fn test_parse_size_fractional_gigabytes() {
653 assert_eq!(parse_size("1.5G").unwrap(), 1610612736);
654 }
655
656 #[test]
657 fn test_parse_size_kilobytes_suffix() {
658 assert_eq!(parse_size("512KB").unwrap(), 524288);
659 }
660
661 #[test]
662 fn test_parse_size_plain_number() {
663 assert_eq!(parse_size("1024").unwrap(), 1024);
664 }
665
666 #[test]
667 fn test_parse_size_k_suffix() {
668 assert_eq!(parse_size("1K").unwrap(), 1024);
669 }
670
671 #[test]
672 fn test_parse_size_terabytes() {
673 assert_eq!(parse_size("1T").unwrap(), 1099511627776);
674 }
675
676 #[test]
677 fn test_parse_size_zero() {
678 assert_eq!(parse_size("0").unwrap(), 0);
679 }
680
681 #[test]
682 fn test_parse_size_invalid_suffix() {
683 assert!(parse_size("100X").is_err());
684 }
685
686 #[test]
687 fn test_parse_size_non_numeric() {
688 assert!(parse_size("abc").is_err());
689 }
690
691 #[test]
692 fn test_parse_size_mb_suffix() {
693 assert_eq!(parse_size("1MB").unwrap(), 1048576);
694 }
695
696 #[test]
697 fn test_parse_size_gb_suffix() {
698 assert_eq!(parse_size("2GB").unwrap(), 2147483648);
699 }
700
701 #[test]
702 fn test_parse_size_whitespace() {
703 assert_eq!(parse_size(" 256M ").unwrap(), 268435456);
704 }
705}