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