Skip to main content

btrfs_cli/filesystem/
usage.rs

1use super::UnitMode;
2use crate::{
3    Format, RunContext, Runnable,
4    util::{SizeFormat, fmt_size},
5};
6use anyhow::{Context, Result};
7use btrfs_uapi::{
8    chunk::device_chunk_allocations,
9    device::device_info_all,
10    filesystem::filesystem_info,
11    space::{BlockGroupFlags, SpaceInfo, space_info},
12};
13use clap::Parser;
14use cols::Cols;
15use std::{
16    collections::{HashMap, HashSet},
17    fs::File,
18    os::unix::io::AsFd,
19    path::PathBuf,
20};
21
22/// Show detailed information about internal filesystem usage
23#[derive(Parser, Debug)]
24pub struct FilesystemUsageCommand {
25    #[clap(flatten)]
26    pub units: UnitMode,
27
28    /// Use base 1000 for human-readable sizes
29    #[clap(short = 'H', long)]
30    pub human_si: bool,
31
32    /// Show data in tabular format
33    #[clap(short = 'T', long)]
34    pub tabular: bool,
35
36    /// One or more mount points to show usage for
37    #[clap(required = true)]
38    pub paths: Vec<PathBuf>,
39}
40
41/// Number of raw-device copies per chunk for a given profile.
42/// Returns 0 for RAID5/6 which require chunk tree data to compute accurately.
43fn profile_ncopies(flags: BlockGroupFlags) -> u64 {
44    if flags.contains(BlockGroupFlags::RAID1C4) {
45        4
46    } else if flags.contains(BlockGroupFlags::RAID1C3) {
47        3
48    } else if flags.contains(BlockGroupFlags::RAID1)
49        || flags.contains(BlockGroupFlags::DUP)
50        || flags.contains(BlockGroupFlags::RAID10)
51    {
52        2
53    } else {
54        u64::from(
55            !(flags.contains(BlockGroupFlags::RAID5)
56                || flags.contains(BlockGroupFlags::RAID6)),
57        )
58    }
59}
60
61fn has_multiple_profiles(spaces: &[SpaceInfo]) -> bool {
62    let profile_mask = BlockGroupFlags::RAID0
63        | BlockGroupFlags::RAID1
64        | BlockGroupFlags::DUP
65        | BlockGroupFlags::RAID10
66        | BlockGroupFlags::RAID5
67        | BlockGroupFlags::RAID6
68        | BlockGroupFlags::RAID1C3
69        | BlockGroupFlags::RAID1C4
70        | BlockGroupFlags::SINGLE;
71
72    let profiles_for = |type_flag: BlockGroupFlags| {
73        spaces
74            .iter()
75            .filter(|s| {
76                s.flags.contains(type_flag)
77                    && !s.flags.contains(BlockGroupFlags::GLOBAL_RSV)
78            })
79            .map(|s| s.flags & profile_mask)
80            .collect::<HashSet<_>>()
81    };
82
83    profiles_for(BlockGroupFlags::DATA).len() > 1
84        || profiles_for(BlockGroupFlags::METADATA).len() > 1
85        || profiles_for(BlockGroupFlags::SYSTEM).len() > 1
86}
87
88impl Runnable for FilesystemUsageCommand {
89    fn run(&self, ctx: &RunContext) -> Result<()> {
90        let mut mode = self.units.resolve();
91        if self.human_si {
92            mode = SizeFormat::HumanSi;
93        }
94        for (i, path) in self.paths.iter().enumerate() {
95            if i > 0 {
96                println!();
97            }
98            match ctx.format {
99                Format::Modern => print_usage_modern(path, &mode)?,
100                Format::Text | Format::Json => {
101                    print_usage(path, self.tabular, &mode)?;
102                }
103            }
104        }
105        Ok(())
106    }
107}
108
109const MIN_UNALLOCATED_THRESH: u64 = 16 * 1024 * 1024;
110
111/// Computed overall stats for a filesystem.
112struct OverallStats {
113    r_total_size: u64,
114    r_total_chunks: u64,
115    r_total_unused: u64,
116    r_total_missing: u64,
117    r_total_used: u64,
118    data_ratio: f64,
119    meta_ratio: f64,
120    free_estimated: u64,
121    free_min: u64,
122    free_statfs: u64,
123    l_global_reserve: u64,
124    l_global_reserve_used: u64,
125    multiple: bool,
126}
127
128#[allow(
129    clippy::cast_precision_loss,
130    clippy::cast_possible_truncation,
131    clippy::cast_sign_loss
132)]
133fn compute_overall(
134    path: &std::path::Path,
135    devices: &[btrfs_uapi::device::DeviceInfo],
136    spaces: &[SpaceInfo],
137) -> OverallStats {
138    let mut r_data_chunks: u64 = 0;
139    let mut r_data_used: u64 = 0;
140    let mut l_data_chunks: u64 = 0;
141    let mut r_meta_chunks: u64 = 0;
142    let mut r_meta_used: u64 = 0;
143    let mut l_meta_chunks: u64 = 0;
144    let mut r_sys_chunks: u64 = 0;
145    let mut r_sys_used: u64 = 0;
146    let mut l_global_reserve: u64 = 0;
147    let mut l_global_reserve_used: u64 = 0;
148    let mut max_ncopies: u64 = 1;
149
150    for s in spaces {
151        if s.flags.contains(BlockGroupFlags::GLOBAL_RSV) {
152            l_global_reserve = s.total_bytes;
153            l_global_reserve_used = s.used_bytes;
154            continue;
155        }
156        let ncopies = profile_ncopies(s.flags);
157        if ncopies > max_ncopies {
158            max_ncopies = ncopies;
159        }
160        if s.flags.contains(BlockGroupFlags::DATA) {
161            r_data_chunks += s.total_bytes * ncopies;
162            r_data_used += s.used_bytes * ncopies;
163            l_data_chunks += s.total_bytes;
164        }
165        if s.flags.contains(BlockGroupFlags::METADATA) {
166            r_meta_chunks += s.total_bytes * ncopies;
167            r_meta_used += s.used_bytes * ncopies;
168            l_meta_chunks += s.total_bytes;
169        }
170        if s.flags.contains(BlockGroupFlags::SYSTEM) {
171            r_sys_chunks += s.total_bytes * ncopies;
172            r_sys_used += s.used_bytes * ncopies;
173        }
174    }
175
176    let r_total_size: u64 = devices.iter().map(|d| d.total_bytes).sum();
177    let r_total_chunks = r_data_chunks + r_meta_chunks + r_sys_chunks;
178    let r_total_used = r_data_used + r_meta_used + r_sys_used;
179    let r_total_unused = r_total_size.saturating_sub(r_total_chunks);
180
181    let r_total_missing: u64 = devices
182        .iter()
183        .filter(|d| std::fs::metadata(&d.path).is_err())
184        .map(|d| d.total_bytes)
185        .sum();
186
187    let data_ratio = if l_data_chunks > 0 {
188        r_data_chunks as f64 / l_data_chunks as f64
189    } else {
190        1.0
191    };
192    let meta_ratio = if l_meta_chunks > 0 {
193        r_meta_chunks as f64 / l_meta_chunks as f64
194    } else {
195        1.0
196    };
197    let max_data_ratio = max_ncopies as f64;
198
199    let free_base = if data_ratio > 0.0 {
200        ((r_data_chunks.saturating_sub(r_data_used)) as f64 / data_ratio) as u64
201    } else {
202        0
203    };
204    let (free_estimated, free_min) = if r_total_unused >= MIN_UNALLOCATED_THRESH
205    {
206        (
207            free_base + (r_total_unused as f64 / data_ratio) as u64,
208            free_base + (r_total_unused as f64 / max_data_ratio) as u64,
209        )
210    } else {
211        (free_base, free_base)
212    };
213
214    let free_statfs = nix::sys::statfs::statfs(path)
215        .map(|st| st.blocks_available() * st.block_size() as u64)
216        .unwrap_or(0);
217
218    let multiple = has_multiple_profiles(spaces);
219
220    OverallStats {
221        r_total_size,
222        r_total_chunks,
223        r_total_unused,
224        r_total_missing,
225        r_total_used,
226        data_ratio,
227        meta_ratio,
228        free_estimated,
229        free_min,
230        free_statfs,
231        l_global_reserve,
232        l_global_reserve_used,
233        multiple,
234    }
235}
236
237#[allow(
238    clippy::too_many_lines,
239    clippy::cast_precision_loss,
240    clippy::cast_possible_truncation,
241    clippy::cast_sign_loss
242)]
243fn print_usage(
244    path: &std::path::Path,
245    _tabular: bool,
246    mode: &SizeFormat,
247) -> Result<()> {
248    let file = File::open(path)
249        .with_context(|| format!("failed to open '{}'", path.display()))?;
250    let fd = file.as_fd();
251
252    let fs = filesystem_info(fd).with_context(|| {
253        format!("failed to get filesystem info for '{}'", path.display())
254    })?;
255    let devices = device_info_all(fd, &fs).with_context(|| {
256        format!("failed to get device info for '{}'", path.display())
257    })?;
258    let spaces = space_info(fd).with_context(|| {
259        format!("failed to get space info for '{}'", path.display())
260    })?;
261
262    // Per-device chunk allocations from the chunk tree.  This requires
263    // CAP_SYS_ADMIN; if it fails we degrade gracefully and note it below.
264    let chunk_allocs = device_chunk_allocations(fd).ok();
265
266    // Map devid -> path for display.
267    let devid_to_path: HashMap<u64, &str> =
268        devices.iter().map(|d| (d.devid, d.path.as_str())).collect();
269
270    let stats = compute_overall(path, &devices, &spaces);
271
272    println!("Overall:");
273    println!(
274        "    Device size:\t\t{:>10}",
275        fmt_size(stats.r_total_size, mode)
276    );
277    println!(
278        "    Device allocated:\t\t{:>10}",
279        fmt_size(stats.r_total_chunks, mode)
280    );
281    println!(
282        "    Device unallocated:\t\t{:>10}",
283        fmt_size(stats.r_total_unused, mode)
284    );
285    println!(
286        "    Device missing:\t\t{:>10}",
287        fmt_size(stats.r_total_missing, mode)
288    );
289    println!("    Device slack:\t\t{:>10}", fmt_size(0, mode));
290    println!("    Used:\t\t\t{:>10}", fmt_size(stats.r_total_used, mode));
291    println!(
292        "    Free (estimated):\t\t{:>10}\t(min: {})",
293        fmt_size(stats.free_estimated, mode),
294        fmt_size(stats.free_min, mode)
295    );
296    println!(
297        "    Free (statfs, df):\t\t{:>10}",
298        fmt_size(stats.free_statfs, mode)
299    );
300    println!("    Data ratio:\t\t\t{:>10.2}", stats.data_ratio);
301    println!("    Metadata ratio:\t\t{:>10.2}", stats.meta_ratio);
302    println!(
303        "    Global reserve:\t\t{:>10}\t(used: {})",
304        fmt_size(stats.l_global_reserve, mode),
305        fmt_size(stats.l_global_reserve_used, mode)
306    );
307    println!(
308        "    Multiple profiles:\t\t{:>10}",
309        if stats.multiple { "yes" } else { "no" }
310    );
311
312    if chunk_allocs.is_none() {
313        eprintln!(
314            "NOTE: per-device usage breakdown unavailable \
315             (chunk tree requires CAP_SYS_ADMIN)"
316        );
317    }
318
319    for s in &spaces {
320        if s.flags.contains(BlockGroupFlags::GLOBAL_RSV) {
321            continue;
322        }
323        #[allow(clippy::cast_precision_loss)]
324        let pct = if s.total_bytes > 0 {
325            100.0 * s.used_bytes as f64 / s.total_bytes as f64
326        } else {
327            0.0
328        };
329        println!(
330            "\n{},{}: Size:{}, Used:{} ({:.2}%)",
331            s.flags.type_name(),
332            s.flags.profile_name(),
333            fmt_size(s.total_bytes, mode),
334            fmt_size(s.used_bytes, mode),
335            pct
336        );
337
338        // Per-device lines: one row per device that holds stripes for this
339        // exact profile.  Sorted by devid for stable output.
340        if let Some(allocs) = &chunk_allocs {
341            let mut profile_allocs: Vec<_> =
342                allocs.iter().filter(|a| a.flags == s.flags).collect();
343            profile_allocs.sort_by_key(|a| a.devid);
344
345            for alloc in profile_allocs {
346                let path = devid_to_path
347                    .get(&alloc.devid)
348                    .copied()
349                    .unwrap_or("<unknown>");
350                println!("   {}\t\t{:>10}", path, fmt_size(alloc.bytes, mode));
351            }
352        }
353    }
354
355    println!("\nUnallocated:");
356    for dev in &devices {
357        let unallocated = dev.total_bytes.saturating_sub(dev.bytes_used);
358        println!("   {}\t{:>10}", dev.path, fmt_size(unallocated, mode));
359    }
360
361    Ok(())
362}
363
364// -- Modern output -----------------------------------------------------------
365
366#[derive(Cols)]
367struct OverallRow {
368    #[column(header = "PROPERTY")]
369    label: String,
370    #[column(header = "VALUE", right)]
371    value: String,
372}
373
374#[derive(Cols)]
375struct ProfileRow {
376    #[column(header = "TYPE")]
377    bg_type: String,
378    #[column(header = "PROFILE")]
379    profile: String,
380    #[column(header = "TOTAL", right)]
381    total: String,
382    #[column(header = "USED", right)]
383    used: String,
384    #[column(header = "USED%", right)]
385    pct: String,
386}
387
388#[allow(
389    clippy::too_many_lines,
390    clippy::cast_precision_loss,
391    clippy::cast_possible_truncation,
392    clippy::cast_sign_loss
393)]
394fn print_usage_modern(path: &std::path::Path, mode: &SizeFormat) -> Result<()> {
395    let file = File::open(path)
396        .with_context(|| format!("failed to open '{}'", path.display()))?;
397    let fd = file.as_fd();
398
399    let fs = filesystem_info(fd).with_context(|| {
400        format!("failed to get filesystem info for '{}'", path.display())
401    })?;
402    let devices = device_info_all(fd, &fs).with_context(|| {
403        format!("failed to get device info for '{}'", path.display())
404    })?;
405    let spaces = space_info(fd).with_context(|| {
406        format!("failed to get space info for '{}'", path.display())
407    })?;
408    let chunk_allocs = device_chunk_allocations(fd).ok();
409
410    let stats = compute_overall(path, &devices, &spaces);
411
412    // Section 1: Overall key-value table
413    println!("Overall:");
414    let mut overall_rows = vec![
415        OverallRow {
416            label: "Device size".to_string(),
417            value: fmt_size(stats.r_total_size, mode),
418        },
419        OverallRow {
420            label: "Device allocated".to_string(),
421            value: fmt_size(stats.r_total_chunks, mode),
422        },
423        OverallRow {
424            label: "Device unallocated".to_string(),
425            value: fmt_size(stats.r_total_unused, mode),
426        },
427        OverallRow {
428            label: "Device missing".to_string(),
429            value: fmt_size(stats.r_total_missing, mode),
430        },
431        OverallRow {
432            label: "Device slack".to_string(),
433            value: fmt_size(0, mode),
434        },
435        OverallRow {
436            label: "Used".to_string(),
437            value: fmt_size(stats.r_total_used, mode),
438        },
439        OverallRow {
440            label: "Free (estimated)".to_string(),
441            value: format!(
442                "{}  (min: {})",
443                fmt_size(stats.free_estimated, mode),
444                fmt_size(stats.free_min, mode)
445            ),
446        },
447        OverallRow {
448            label: "Free (statfs, df)".to_string(),
449            value: fmt_size(stats.free_statfs, mode),
450        },
451        OverallRow {
452            label: "Data ratio".to_string(),
453            value: format!("{:.2}", stats.data_ratio),
454        },
455        OverallRow {
456            label: "Metadata ratio".to_string(),
457            value: format!("{:.2}", stats.meta_ratio),
458        },
459    ];
460
461    overall_rows.push(OverallRow {
462        label: "Global reserve".to_string(),
463        value: format!(
464            "{}  (used: {})",
465            fmt_size(stats.l_global_reserve, mode),
466            fmt_size(stats.l_global_reserve_used, mode)
467        ),
468    });
469    overall_rows.push(OverallRow {
470        label: "Multiple profiles".to_string(),
471        value: if stats.multiple { "yes" } else { "no" }.to_string(),
472    });
473
474    let mut out = std::io::stdout().lock();
475    let _ = OverallRow::print_table(&overall_rows, &mut out);
476
477    if chunk_allocs.is_none() {
478        eprintln!(
479            "NOTE: per-device usage breakdown unavailable \
480             (chunk tree requires CAP_SYS_ADMIN)"
481        );
482    }
483
484    // Section 2: Profile summary table
485    let profile_rows: Vec<ProfileRow> = spaces
486        .iter()
487        .filter(|s| !s.flags.contains(BlockGroupFlags::GLOBAL_RSV))
488        .map(|s| {
489            let pct = if s.total_bytes > 0 {
490                100.0 * s.used_bytes as f64 / s.total_bytes as f64
491            } else {
492                0.0
493            };
494            ProfileRow {
495                bg_type: s.flags.type_name().to_string(),
496                profile: s.flags.profile_name().to_string(),
497                total: fmt_size(s.total_bytes, mode),
498                used: fmt_size(s.used_bytes, mode),
499                pct: format!("{pct:.2}%"),
500            }
501        })
502        .collect();
503
504    if !profile_rows.is_empty() {
505        println!();
506        let _ = ProfileRow::print_table(&profile_rows, &mut out);
507    }
508
509    // Section 3: Per-device allocation table (dynamic columns)
510    if let Some(allocs) = &chunk_allocs {
511        // Collect unique profiles in display order.
512        let mut profile_flags: Vec<BlockGroupFlags> = Vec::new();
513        for s in &spaces {
514            if s.flags.contains(BlockGroupFlags::GLOBAL_RSV) {
515                continue;
516            }
517            if !profile_flags.contains(&s.flags) {
518                profile_flags.push(s.flags);
519            }
520        }
521
522        let mut table = cols::Table::new();
523        table.add_column(cols::Column::new("PATH"));
524        for flags in &profile_flags {
525            table.add_column(
526                cols::Column::new(&format!(
527                    "{},{}",
528                    flags.type_name(),
529                    flags.profile_name()
530                ))
531                .right(true),
532            );
533        }
534        table.add_column(cols::Column::new("UNALLOC").right(true));
535
536        for dev in &devices {
537            let line = table.new_line(None);
538            let row = table.line_mut(line);
539            row.data_set(0, &dev.path);
540
541            let mut allocated: u64 = 0;
542            for (ci, flags) in profile_flags.iter().enumerate() {
543                let bytes: u64 = allocs
544                    .iter()
545                    .filter(|a| a.devid == dev.devid && a.flags == *flags)
546                    .map(|a| a.bytes)
547                    .sum();
548                allocated += bytes;
549                if bytes > 0 {
550                    row.data_set(ci + 1, &fmt_size(bytes, mode));
551                } else {
552                    row.data_set(ci + 1, "-");
553                }
554            }
555
556            let unallocated = dev.total_bytes.saturating_sub(allocated);
557            row.data_set(profile_flags.len() + 1, &fmt_size(unallocated, mode));
558        }
559
560        println!();
561        let _ = cols::print_table(&table, &mut out);
562    }
563
564    Ok(())
565}