Skip to main content

btrfs_cli/
util.rs

1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Local};
3use serde::Serialize;
4use std::{
5    fs::{self, File},
6    os::unix::fs::FileTypeExt,
7    path::Path,
8    str::FromStr,
9    time::{SystemTime, UNIX_EPOCH},
10};
11use uuid::Uuid;
12
13/// Open a path and return the `File`, with a contextual error message on failure.
14pub fn open_path(path: &Path) -> Result<File> {
15    File::open(path)
16        .with_context(|| format!("failed to open '{}'", path.display()))
17}
18
19/// Return `true` if `device` appears as a source in `/proc/mounts`.
20///
21/// Compares canonical paths so symlinks in `/dev/disk/by-*` are handled
22/// Check whether a device path appears as a mount source in `/proc/mounts`.
23///
24/// Delegates to [`btrfs_uapi::filesystem::is_mounted`].
25pub fn is_mounted(device: &Path) -> bool {
26    btrfs_uapi::filesystem::is_mounted(device).unwrap_or(false)
27}
28
29/// Resolved size display mode.
30pub enum SizeFormat {
31    /// Print raw byte count with no suffix.
32    Raw,
33    /// Human-readable with base 1024 (KiB, MiB, GiB, …).
34    HumanIec,
35    /// Human-readable with base 1000 (kB, MB, GB, …).
36    HumanSi,
37    /// Divide by a fixed power and print the integer result.
38    Fixed(u64),
39}
40
41/// Format a byte count according to the given [`SizeFormat`].
42pub fn fmt_size(bytes: u64, mode: &SizeFormat) -> String {
43    match mode {
44        SizeFormat::Raw => bytes.to_string(),
45        SizeFormat::HumanIec => human_bytes(bytes),
46        SizeFormat::HumanSi => human_bytes_si(bytes),
47        SizeFormat::Fixed(divisor) => format!("{}", bytes / divisor),
48    }
49}
50
51/// Format a byte count as a human-readable string using binary prefixes.
52#[allow(clippy::cast_precision_loss)]
53pub fn human_bytes(bytes: u64) -> String {
54    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
55    let mut value = bytes as f64;
56    let mut unit = 0;
57    while value >= 1024.0 && unit + 1 < UNITS.len() {
58        value /= 1024.0;
59        unit += 1;
60    }
61    format!("{value:.2}{}", UNITS[unit])
62}
63
64/// Format a byte count as a human-readable string using SI (base-1000) prefixes.
65#[allow(clippy::cast_precision_loss)]
66pub fn human_bytes_si(bytes: u64) -> String {
67    const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB"];
68    let mut value = bytes as f64;
69    let mut unit = 0;
70    while value >= 1000.0 && unit + 1 < UNITS.len() {
71        value /= 1000.0;
72        unit += 1;
73    }
74    format!("{value:.2}{}", UNITS[unit])
75}
76
77/// Format a [`SystemTime`] as a local-time datetime string in the same style
78/// as the C btrfs-progs tool: `YYYY-MM-DD HH:MM:SS ±HHMM`.
79///
80/// Returns `"-"` when the time is [`UNIX_EPOCH`] (i.e. not set).
81pub fn format_time(t: SystemTime) -> String {
82    if t == UNIX_EPOCH {
83        return "-".to_string();
84    }
85    match DateTime::<Local>::from(t)
86        .format("%Y-%m-%d %H:%M:%S %z")
87        .to_string()
88    {
89        s if s.is_empty() => "-".to_string(),
90        s => s,
91    }
92}
93
94/// Format a [`SystemTime`] for replace-status output: `%e.%b %T`
95/// (e.g. ` 5.Mar 14:30:00`).
96pub fn format_time_short(t: &SystemTime) -> String {
97    DateTime::<Local>::from(*t).format("%e.%b %T").to_string()
98}
99
100/// Format a unix timestamp (sec, nsec) as `sec.nsec (YYYY-MM-DD HH:MM:SS)`.
101pub fn format_timespec(sec: u64, nsec: u32) -> String {
102    #[allow(clippy::cast_possible_wrap)] // timestamps fit in i64
103    let sec_i64 = sec as i64;
104    match DateTime::from_timestamp(sec_i64, nsec) {
105        Some(utc) => {
106            let local = utc.with_timezone(&Local);
107            format!("{}.{} ({})", sec, nsec, local.format("%Y-%m-%d %H:%M:%S"))
108        }
109        None => format!("{sec}.{nsec}"),
110    }
111}
112
113/// Parse a size string with an optional binary suffix (K, M, G, T, P, E).
114pub fn parse_size_with_suffix(s: &str) -> Result<u64> {
115    let (num_str, suffix) = match s.find(|c: char| c.is_alphabetic()) {
116        Some(i) => (&s[..i], &s[i..]),
117        None => (s, ""),
118    };
119    let n: u64 = num_str
120        .parse()
121        .with_context(|| format!("invalid size number: '{num_str}'"))?;
122    let multiplier: u64 = match suffix.to_uppercase().as_str() {
123        "" => 1,
124        "K" => 1024,
125        "M" => 1024 * 1024,
126        "G" => 1024 * 1024 * 1024,
127        "T" => 1024u64.pow(4),
128        "P" => 1024u64.pow(5),
129        "E" => 1024u64.pow(6),
130        _ => anyhow::bail!("unknown size suffix: '{suffix}'"),
131    };
132    n.checked_mul(multiplier)
133        .ok_or_else(|| anyhow::anyhow!("size overflow: '{s}'"))
134}
135
136/// A UUID value parsed from a CLI argument.
137///
138/// Accepts `clear` (nil UUID), `random` (random v4 UUID), `time` (v7
139/// time-ordered UUID), or any standard UUID string (with or without hyphens).
140#[derive(Debug, Clone, Copy)]
141pub struct ParsedUuid(Uuid);
142
143impl std::ops::Deref for ParsedUuid {
144    type Target = Uuid;
145    fn deref(&self) -> &Uuid {
146        &self.0
147    }
148}
149
150/// Parse a qgroup ID string of the form `"<level>/<subvolid>"` into a packed u64.
151///
152/// The packed form is `(level as u64) << 48 | subvolid`.
153/// Example: `"0/5"` → `5`, `"1/256"` → `0x0001_0000_0000_0100`.
154pub fn parse_qgroupid(s: &str) -> anyhow::Result<u64> {
155    let (level_str, id_str) = s.split_once('/').ok_or_else(|| {
156        anyhow::anyhow!("invalid qgroup ID '{s}': expected <level>/<id>")
157    })?;
158    let level: u64 = level_str.parse().map_err(|_| {
159        anyhow::anyhow!("invalid qgroup level '{level_str}' in '{s}'")
160    })?;
161    let subvolid: u64 = id_str.parse().map_err(|_| {
162        anyhow::anyhow!("invalid qgroup subvolid '{id_str}' in '{s}'")
163    })?;
164    Ok((level << 48) | subvolid)
165}
166
167impl FromStr for ParsedUuid {
168    type Err = String;
169
170    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
171        match s {
172            "clear" => Ok(Self(Uuid::nil())),
173            "random" => Ok(Self(Uuid::new_v4())),
174            "time" => Ok(Self(Uuid::now_v7())),
175            _ => Uuid::parse_str(s)
176                .map(Self)
177                .map_err(|e| format!("invalid UUID: {e}")),
178        }
179    }
180}
181
182/// Check that a device is suitable for use as a btrfs target (add or replace).
183///
184/// Verifies that the path is a block device, is not currently mounted, and
185/// does not already contain a btrfs filesystem (unless `force` is true).
186pub fn check_device_for_overwrite(device: &Path, force: bool) -> Result<()> {
187    let meta = fs::metadata(device).with_context(|| {
188        format!("cannot access device '{}'", device.display())
189    })?;
190
191    if !meta.file_type().is_block_device() {
192        bail!("'{}' is not a block device", device.display());
193    }
194
195    if is_device_mounted(device)? {
196        bail!(
197            "'{}' is mounted; refusing to use a mounted device",
198            device.display()
199        );
200    }
201
202    if !force && has_btrfs_superblock(device) {
203        bail!(
204            "'{}' already contains a btrfs filesystem; use -f to force",
205            device.display()
206        );
207    }
208
209    Ok(())
210}
211
212/// Check if a device path appears in /proc/mounts (with error propagation).
213///
214/// Delegates to [`btrfs_uapi::filesystem::is_mounted`].
215pub fn is_device_mounted(device: &Path) -> Result<bool> {
216    btrfs_uapi::filesystem::is_mounted(device).with_context(|| {
217        format!("cannot check mount status of '{}'", device.display())
218    })
219}
220
221/// Try to read a btrfs superblock from the device. Returns true if a valid
222/// btrfs magic signature is found.
223pub fn has_btrfs_superblock(device: &Path) -> bool {
224    let Ok(mut file) = File::open(device) else {
225        return false;
226    };
227    match btrfs_disk::superblock::read_superblock(&mut file, 0) {
228        Ok(sb) => sb.magic_is_valid(),
229        Err(_) => false,
230    }
231}
232
233/// Write structured JSON output in the btrfs-progs format.
234///
235/// Wraps the data in a root object with a `__header` containing a version
236/// field, and the data under the given `key`:
237///
238/// ```json
239/// {
240///   "__header": { "version": "1" },
241///   "<key>": <data>
242/// }
243/// ```
244pub fn print_json(key: &str, data: &impl Serialize) -> Result<()> {
245    #[derive(Serialize)]
246    struct Header {
247        version: &'static str,
248    }
249
250    let mut map = serde_json::Map::new();
251    map.insert(
252        "__header".to_string(),
253        serde_json::to_value(Header { version: "1" })?,
254    );
255    map.insert(key.to_string(), serde_json::to_value(data)?);
256
257    serde_json::to_writer_pretty(std::io::stdout(), &map)?;
258    println!();
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // --- human_bytes ---
267
268    #[test]
269    fn human_bytes_zero() {
270        assert_eq!(human_bytes(0), "0.00B");
271    }
272
273    #[test]
274    fn human_bytes_small() {
275        assert_eq!(human_bytes(1), "1.00B");
276        assert_eq!(human_bytes(1023), "1023.00B");
277    }
278
279    #[test]
280    fn human_bytes_exact_powers() {
281        assert_eq!(human_bytes(1024), "1.00KiB");
282        assert_eq!(human_bytes(1024 * 1024), "1.00MiB");
283        assert_eq!(human_bytes(1024 * 1024 * 1024), "1.00GiB");
284        assert_eq!(human_bytes(1024u64.pow(4)), "1.00TiB");
285        assert_eq!(human_bytes(1024u64.pow(5)), "1.00PiB");
286    }
287
288    #[test]
289    fn human_bytes_fractional() {
290        // 1.5 GiB = 1024^3 + 512*1024^2
291        assert_eq!(
292            human_bytes(1024 * 1024 * 1024 + 512 * 1024 * 1024),
293            "1.50GiB"
294        );
295    }
296
297    #[test]
298    fn human_bytes_u64_max() {
299        // Should not panic; lands in PiB range
300        let s = human_bytes(u64::MAX);
301        assert!(s.ends_with("PiB"), "expected PiB suffix, got: {s}");
302    }
303
304    // --- parse_size_with_suffix ---
305
306    #[test]
307    fn parse_size_bare_number() {
308        assert_eq!(parse_size_with_suffix("0").unwrap(), 0);
309        assert_eq!(parse_size_with_suffix("42").unwrap(), 42);
310    }
311
312    #[test]
313    fn parse_size_all_suffixes() {
314        assert_eq!(parse_size_with_suffix("1K").unwrap(), 1024);
315        assert_eq!(parse_size_with_suffix("1M").unwrap(), 1024 * 1024);
316        assert_eq!(parse_size_with_suffix("1G").unwrap(), 1024 * 1024 * 1024);
317        assert_eq!(parse_size_with_suffix("1T").unwrap(), 1024u64.pow(4));
318        assert_eq!(parse_size_with_suffix("1P").unwrap(), 1024u64.pow(5));
319        assert_eq!(parse_size_with_suffix("1E").unwrap(), 1024u64.pow(6));
320    }
321
322    #[test]
323    fn parse_size_case_insensitive() {
324        assert_eq!(parse_size_with_suffix("4k").unwrap(), 4 * 1024);
325        assert_eq!(
326            parse_size_with_suffix("2g").unwrap(),
327            2 * 1024 * 1024 * 1024
328        );
329    }
330
331    #[test]
332    fn parse_size_overflow() {
333        assert!(parse_size_with_suffix("16385P").is_err());
334    }
335
336    #[test]
337    fn parse_size_bad_number() {
338        assert!(parse_size_with_suffix("abcM").is_err());
339        assert!(parse_size_with_suffix("").is_err());
340    }
341
342    #[test]
343    fn parse_size_unknown_suffix() {
344        assert!(parse_size_with_suffix("10X").is_err());
345    }
346
347    // --- parse_qgroupid ---
348
349    #[test]
350    fn parse_qgroupid_level0() {
351        assert_eq!(parse_qgroupid("0/5").unwrap(), 5);
352        assert_eq!(parse_qgroupid("0/256").unwrap(), 256);
353    }
354
355    #[test]
356    fn parse_qgroupid_higher_level() {
357        assert_eq!(parse_qgroupid("1/256").unwrap(), (1u64 << 48) | 256);
358        assert_eq!(parse_qgroupid("2/0").unwrap(), 2u64 << 48);
359    }
360
361    #[test]
362    fn parse_qgroupid_missing_slash() {
363        assert!(parse_qgroupid("5").is_err());
364    }
365
366    #[test]
367    fn parse_qgroupid_bad_level() {
368        assert!(parse_qgroupid("abc/5").is_err());
369    }
370
371    #[test]
372    fn parse_qgroupid_bad_subvolid() {
373        assert!(parse_qgroupid("0/abc").is_err());
374    }
375
376    // --- ParsedUuid ---
377
378    #[test]
379    fn parsed_uuid_clear() {
380        let u: ParsedUuid = "clear".parse().unwrap();
381        assert!(u.is_nil());
382    }
383
384    #[test]
385    fn parsed_uuid_random() {
386        let u: ParsedUuid = "random".parse().unwrap();
387        assert!(!u.is_nil());
388    }
389
390    #[test]
391    fn parsed_uuid_time() {
392        let u: ParsedUuid = "time".parse().unwrap();
393        assert!(!u.is_nil());
394    }
395
396    #[test]
397    fn parsed_uuid_explicit() {
398        let u: ParsedUuid =
399            "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
400        assert_eq!(u.to_string(), "550e8400-e29b-41d4-a716-446655440000");
401    }
402
403    #[test]
404    fn parsed_uuid_no_hyphens() {
405        let u: ParsedUuid = "550e8400e29b41d4a716446655440000".parse().unwrap();
406        assert_eq!(u.to_string(), "550e8400-e29b-41d4-a716-446655440000");
407    }
408
409    #[test]
410    fn parsed_uuid_invalid() {
411        assert!("not-a-uuid".parse::<ParsedUuid>().is_err());
412        assert!("".parse::<ParsedUuid>().is_err());
413    }
414}