Skip to main content

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            &params.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}