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_core::store::StorageBackend;
164use hexz_core::store::local::FileBackend;
165use hexz_fuse::fuse::Hexz;
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(hexz_path: &str, cache_size: Option<String>) -> Result<Arc<File>> {
256 let abs_hexz_path = std::fs::canonicalize(hexz_path)
257 .context(format!("Failed to resolve snapshot path: {}", hexz_path))?;
258
259 // Pre-read header for password prompt
260 let (header, password) = {
261 let backend = FileBackend::new(&abs_hexz_path)?;
262 let header_bytes = backend.read_exact(0, HEADER_SIZE)?;
263 let header: Header = bincode::deserialize(&header_bytes)?;
264
265 let password = if header.encryption.is_some() {
266 Some(rpassword::prompt_password("Enter encryption password: ")?)
267 } else {
268 None
269 };
270 (header, password)
271 };
272
273 let backend = Arc::new(FileBackend::new(&abs_hexz_path)?);
274
275 let dictionary = if let (Some(offset), Some(length)) =
276 (header.dictionary_offset, header.dictionary_length)
277 {
278 Some(backend.read_exact(offset, length as usize)?.to_vec())
279 } else {
280 None
281 };
282
283 let compressor: Box<dyn Compressor> = match header.compression {
284 CompressionType::Lz4 => Box::new(Lz4Compressor::new()),
285 CompressionType::Zstd => Box::new(ZstdCompressor::new(DEFAULT_ZSTD_LEVEL, dictionary)),
286 };
287
288 let encryptor = if let (Some(params), Some(pass)) = (header.encryption, password) {
289 Some(Box::new(AesGcmEncryptor::new(
290 pass.as_bytes(),
291 ¶ms.salt,
292 params.iterations,
293 )?)
294 as Box<dyn hexz_core::algo::encryption::Encryptor>)
295 } else {
296 None
297 };
298
299 let cache_capacity = if let Some(s) = cache_size {
300 Some(parse_size(&s)?)
301 } else {
302 None
303 };
304
305 Ok(File::with_cache(
306 backend,
307 compressor,
308 encryptor,
309 cache_capacity,
310 None, // No prefetching for mount command
311 )?)
312}
313
314/// Executes the mount command to expose a snapshot via FUSE or NBD.
315///
316/// Mounts a Hexz snapshot at the specified mountpoint using either FUSE
317/// (default) or NBD (if `--nbd` flag is set). Supports read-only and read-write
318/// modes, optional overlay for copy-on-write, caching, and daemon mode.
319///
320/// # Arguments
321///
322/// * `hexz_path` - Path to the `.st` snapshot file
323/// * `mountpoint` - Directory where snapshot will be mounted
324/// * `overlay` - Optional overlay file path for read-write mounts (persistent)
325/// * `daemon` - If true, daemonize the process and run in background
326/// * `rw` - If true, mount read-write with overlay; otherwise read-only
327/// * `cache_size` - Optional cache size (e.g., "256M", "1G")
328/// * `uid` - User ID for file ownership inside mount
329/// * `gid` - Group ID for file ownership inside mount
330/// * `nbd` - If true, use NBD instead of FUSE (requires root)
331///
332/// # FUSE Mode (Default)
333///
334/// Creates a FUSE filesystem at `mountpoint` with:
335/// - `disk` file containing disk image
336/// - `memory` file containing memory dump (if present in snapshot)
337///
338/// **Read-Only Mode:**
339/// - All writes are rejected with EROFS error
340/// - No overlay file created
341/// - Safe for concurrent mounts of same snapshot
342///
343/// **Read-Write Mode:**
344/// - Writes captured in overlay file (4 KiB granularity)
345/// - Metadata tracked in `.meta` file
346/// - Overlay can be persistent (specified path) or ephemeral (temp file)
347///
348/// **Daemon Mode:**
349/// - Process detaches and runs in background
350/// - Logs redirected to `/tmp/hexz.log` and `/tmp/hexz.err`
351/// - Working directory changed to `/`
352/// - Useful for long-running mounts
353///
354/// # NBD Mode (`--nbd`)
355///
356/// Creates an NBD block device and mounts it:
357/// 1. Starts internal NBD server on ephemeral port (localhost only)
358/// 2. Runs `nbd-client` to connect to server and create `/dev/nbdN`
359/// 3. Runs `mount` to mount the NBD device at `mountpoint`
360/// 4. Waits for Ctrl+C
361/// 5. Unmounts and cleans up NBD device
362///
363/// **Requirements:**
364/// - Root privileges (uses `sudo` for `nbd-client` and `mount`)
365/// - `nbd` kernel module loaded (`sudo modprobe nbd`)
366/// - `nbd-client` utility installed
367///
368/// **NBD Server:**
369/// - Binds to `127.0.0.1` (localhost only, not exposed to network)
370/// - Automatically selects free ephemeral port
371/// - Serves disk stream only (memory not exposed via NBD)
372///
373/// # Overlay File Paths
374///
375/// If `overlay` is specified:
376/// - Absolute path: Used as-is
377/// - Relative path: Resolved relative to current working directory
378/// - Path does not need to exist (will be created)
379///
380/// If `overlay` is None and `rw` is true:
381/// - Creates temporary file that is deleted on unmount
382/// - Useful for ephemeral changes (testing, development)
383///
384/// # Errors
385///
386/// Returns an error if:
387/// - Snapshot file cannot be opened
388/// - Mountpoint does not exist or is not a directory
389/// - FUSE mount fails (permissions, already mounted)
390/// - NBD server fails to start (port unavailable, feature not compiled)
391/// - NBD client/mount commands fail (not installed, wrong permissions)
392/// - Daemonization fails (resource limits, permissions)
393///
394/// # Examples
395///
396/// ```no_run
397/// use std::path::PathBuf;
398/// use hexz_cli::cmd::vm::mount;
399///
400/// // Read-only FUSE mount
401/// mount::run(
402/// "snapshot.hxz".to_string(),
403/// PathBuf::from("/mnt"),
404/// None,
405/// false, // not daemon
406/// false, // read-only
407/// None, // no cache
408/// 1000, // uid
409/// 1000, // gid
410/// false, // FUSE mode
411/// )?;
412///
413/// // Read-write mount with persistent overlay
414/// mount::run(
415/// "snapshot.hxz".to_string(),
416/// PathBuf::from("/mnt"),
417/// Some(PathBuf::from("changes.overlay")),
418/// false,
419/// true, // read-write
420/// Some("512M".to_string()), // 512 MB cache
421/// 1000,
422/// 1000,
423/// false,
424/// )?;
425/// # Ok::<(), anyhow::Error>(())
426/// ```
427#[allow(clippy::too_many_arguments)]
428pub fn run(
429 hexz_path: String,
430 mountpoint: PathBuf,
431 overlay: Option<PathBuf>,
432 daemon: bool,
433 rw: bool,
434 cache_size: Option<String>,
435 uid: u32,
436 gid: u32,
437 nbd: bool,
438) -> Result<()> {
439 if nbd {
440 #[cfg(feature = "server")]
441 return run_nbd(hexz_path, mountpoint, cache_size);
442 #[cfg(not(feature = "server"))]
443 anyhow::bail!("NBD support requires the 'server' feature");
444 }
445
446 // --- FUSE Implementation ---
447
448 let abs_mountpoint = std::fs::canonicalize(&mountpoint)
449 .context(format!("Failed to resolve mountpoint: {:?}", mountpoint))?;
450
451 // FIX: Don't use canonicalize on the overlay path directly, as it fails if the file doesn't exist.
452 // Instead, resolve it relative to current dir if needed, or just pass it through.
453 // Hexz::new handles creation.
454 let abs_overlay_path = if let Some(p) = &overlay {
455 if p.is_absolute() {
456 Some(p.clone())
457 } else {
458 Some(std::env::current_dir()?.join(p))
459 }
460 } else {
461 None
462 };
463
464 // Open snapshot
465 let snap = open_snapshot(&hexz_path, cache_size)?;
466
467 // Daemonize if requested
468 if daemon {
469 let log_dir = std::env::var("XDG_RUNTIME_DIR")
470 .or_else(|_| std::env::var("TMPDIR"))
471 .unwrap_or_else(|_| "/tmp".to_string());
472 let stdout = std::fs::File::create(format!("{}/hexz.log", log_dir))
473 .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
474 let stderr = std::fs::File::create(format!("{}/hexz.err", log_dir))
475 .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
476
477 Daemonize::new()
478 .working_directory("/")
479 .stdout(stdout)
480 .stderr(stderr)
481 .start()?;
482 }
483
484 // Handle RW overlay
485 let (_temp_file, final_overlay_path) = if let Some(p) = abs_overlay_path {
486 (None, Some(p))
487 } else if rw {
488 let t = tempfile::NamedTempFile::new()?;
489 let path = t.path().to_path_buf();
490 let meta = path.with_extension("meta");
491 std::fs::File::create(&meta)?;
492 (Some(t), Some(path))
493 } else {
494 (None, None)
495 };
496
497 let mut options = vec![
498 fuser::MountOption::FSName("hexz".to_string()),
499 fuser::MountOption::DefaultPermissions,
500 ];
501
502 if rw {
503 options.push(fuser::MountOption::RW);
504 } else {
505 options.push(fuser::MountOption::RO);
506 }
507
508 let fs = Hexz::new(snap, final_overlay_path.as_deref(), uid, gid)?;
509
510 if daemon {
511 eprintln!("Mounting at {:?} (daemonized)", abs_mountpoint);
512 }
513
514 fuser::mount2(fs, abs_mountpoint, &options)?;
515
516 Ok(())
517}
518
519#[cfg(feature = "server")]
520fn run_nbd(hexz_path: String, mountpoint: PathBuf, cache_size: Option<String>) -> Result<()> {
521 // 1. Check for sudo/root (NBD requires it)
522 let is_root = unsafe { libc::geteuid() == 0 };
523 if !is_root {
524 println!("Note: NBD mounting requires root privileges. You may be prompted for sudo.");
525 }
526
527 // 2. Open Snapshot
528 let snap = open_snapshot(&hexz_path, cache_size)?;
529
530 // 3. Find a free NBD device
531 let nbd_dev = find_free_nbd_device()?;
532 println!("Selected NBD device: {}", nbd_dev);
533
534 // 4. Start Server in background runtime
535 let rt = tokio::runtime::Runtime::new()?;
536
537 // Bind to ephemeral port
538 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
539 let port = listener.local_addr()?.port();
540 drop(listener); // Close so server can bind
541
542 println!("Starting internal NBD server on port {}...", port);
543
544 let snap_clone = snap.clone();
545 rt.spawn(async move {
546 if let Err(e) = hexz_server::serve_nbd(snap_clone, port).await {
547 eprintln!("NBD Server error: {}", e);
548 }
549 });
550
551 // Give server a moment to start
552 std::thread::sleep(std::time::Duration::from_millis(200));
553
554 // 5. Connect Client
555 println!("Connecting NBD client...");
556 let status = Command::new("sudo")
557 .arg("nbd-client")
558 .arg("localhost")
559 .arg(port.to_string())
560 .arg(&nbd_dev)
561 .status()
562 .context("Failed to run nbd-client")?;
563
564 if !status.success() {
565 anyhow::bail!("nbd-client failed. Is the 'nbd' kernel module loaded?");
566 }
567
568 // 6. Mount
569 println!("Mounting {} to {:?}...", nbd_dev, mountpoint);
570 let status = Command::new("sudo")
571 .arg("mount")
572 .arg(&nbd_dev)
573 .arg(&mountpoint)
574 .status()
575 .context("Failed to run mount")?;
576
577 if !status.success() {
578 // Cleanup NBD if mount fails
579 let _ = Command::new("sudo")
580 .arg("nbd-client")
581 .arg("-d")
582 .arg(&nbd_dev)
583 .status();
584 anyhow::bail!("Mount command failed.");
585 }
586
587 println!("Successfully mounted via NBD.");
588 println!("Press Ctrl+C to unmount and cleanup.");
589
590 // 7. Wait for Ctrl+C
591 let running = Arc::new(AtomicBool::new(true));
592 let r = running.clone();
593
594 ctrlc::set_handler(move || {
595 r.store(false, Ordering::SeqCst);
596 })?;
597
598 while running.load(Ordering::SeqCst) {
599 std::thread::sleep(std::time::Duration::from_millis(100));
600 }
601
602 // 8. Cleanup
603 println!("\nCleaning up...");
604
605 let _ = Command::new("sudo").arg("umount").arg(&mountpoint).status();
606 let _ = Command::new("sudo")
607 .arg("nbd-client")
608 .arg("-d")
609 .arg(&nbd_dev)
610 .status();
611
612 Ok(())
613}
614
615#[cfg(feature = "server")]
616fn find_free_nbd_device() -> Result<String> {
617 // Simple heuristic: Try to find a device that isn't connected.
618 // We check /sys/class/block/nbd*/pid. If file exists and contains content, it's busy.
619 // Or simpler: /sys/class/block/nbd*/size is 0 for disconnected devices.
620
621 for i in 0..16 {
622 let dev = format!("/dev/nbd{}", i);
623 let sys_path = format!("/sys/class/block/nbd{}/size", i);
624
625 if Path::new(&sys_path).exists() {
626 let content = std::fs::read_to_string(&sys_path).unwrap_or_default();
627 if content.trim() == "0" {
628 return Ok(dev);
629 }
630 }
631 }
632 anyhow::bail!("No free /dev/nbd devices found. Try 'sudo modprobe nbd max_part=8'")
633}
634
635#[cfg(test)]
636mod tests {
637 use super::parse_size;
638
639 #[test]
640 fn test_parse_size_megabytes() {
641 assert_eq!(parse_size("256M").unwrap(), 268435456);
642 }
643
644 #[test]
645 fn test_parse_size_fractional_gigabytes() {
646 assert_eq!(parse_size("1.5G").unwrap(), 1610612736);
647 }
648
649 #[test]
650 fn test_parse_size_kilobytes_suffix() {
651 assert_eq!(parse_size("512KB").unwrap(), 524288);
652 }
653
654 #[test]
655 fn test_parse_size_plain_number() {
656 assert_eq!(parse_size("1024").unwrap(), 1024);
657 }
658
659 #[test]
660 fn test_parse_size_k_suffix() {
661 assert_eq!(parse_size("1K").unwrap(), 1024);
662 }
663
664 #[test]
665 fn test_parse_size_terabytes() {
666 assert_eq!(parse_size("1T").unwrap(), 1099511627776);
667 }
668
669 #[test]
670 fn test_parse_size_zero() {
671 assert_eq!(parse_size("0").unwrap(), 0);
672 }
673
674 #[test]
675 fn test_parse_size_invalid_suffix() {
676 assert!(parse_size("100X").is_err());
677 }
678
679 #[test]
680 fn test_parse_size_non_numeric() {
681 assert!(parse_size("abc").is_err());
682 }
683
684 #[test]
685 fn test_parse_size_mb_suffix() {
686 assert_eq!(parse_size("1MB").unwrap(), 1048576);
687 }
688
689 #[test]
690 fn test_parse_size_gb_suffix() {
691 assert_eq!(parse_size("2GB").unwrap(), 2147483648);
692 }
693
694 #[test]
695 fn test_parse_size_whitespace() {
696 assert_eq!(parse_size(" 256M ").unwrap(), 268435456);
697 }
698}