Skip to content

Commit 2d0b4e0

Browse files
MagicalTuxclaude
andcommitted
feat(fs): statfs without an allocator, on every driver and generically
`fs::StatFs` moves out of the alloc-only API into a module compiled in every configuration (same path, `fstool::fs::StatFs`), gains `total_bytes` / `free_bytes` / `avail_bytes`, and derives `PartialEq`/`Eq`. The allocator-free FAT, exFAT and littlefs volumes answer `statfs(&mut self)` with it in their own allocation units — clusters or erase blocks — and `fs::volume::Volume` has it too, so `AnyVolume` does. The trait method is provided (derived from `total_bytes` / `free_bytes` in 512-byte units), so implementations written against 0.4.32 keep compiling. Checked against the tools: the counts match what `fsck.vfat` walks out of the FAT, what `dump.exfat` reads from the bitmap, and littlefs's own used-block traversal through `littlefs-python`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3f9a05a commit 2d0b4e0

16 files changed

Lines changed: 289 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- *(fs)* `Volume::statfs` on the allocator-free FAT, exFAT and littlefs
13+
drivers, and on the `fs::volume::Volume` trait (so on `AnyVolume` too):
14+
capacity in `statfs` shape — the allocation unit (cluster or erase block),
15+
how many there are and how many are free, and the longest name. It
16+
answers with the same `fs::StatFs` the hosted `Filesystem::statfs` does,
17+
which is now compiled in every configuration, not only with `alloc`, and
18+
gains `total_bytes` / `free_bytes` / `avail_bytes`. The counts agree with
19+
what `fsck.vfat` and `dump.exfat` work out from the volume, and with
20+
littlefs's own traversal through `littlefs-python`. The trait method is
21+
provided — derived from `total_bytes` / `free_bytes` in 512-byte units —
22+
so an implementation written against 0.4.32 still compiles.
23+
24+
### Changed
25+
26+
- *(fs)* `StatFs` derives `PartialEq` and `Eq`.
27+
1028
## [0.4.32](https://github.com/KarpelesLab/fstool/compare/v0.4.31...v0.4.32) - 2026-09-15
1129

1230
### Added

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,11 @@ fn on_insert(card: SdCard) -> Result<SdCard, fstool::fs::volume::AnyError<MyDriv
981981
}
982982
```
983983

984+
`statfs()` answers capacity the same way on every one of them — the
985+
`fs::StatFs` struct the hosted `Filesystem::statfs` returns, compiled without
986+
a heap: allocation unit, units in all and free, longest name, plus
987+
`total_bytes()` / `free_bytes()` helpers.
988+
984989
Errors keep their driver's detail — `AnyError::Fat(fat::Error::NoSpace)`
985990
and every one of them also answers `kind()` with a shared `ErrorKind`, so
986991
generic code can tell `NotFound` from `Io` without knowing whose error it

src/fs/api.rs

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use alloc::string::String;
2323
use alloc::vec;
2424
use alloc::vec::Vec;
2525

26+
use super::StatFs;
27+
2628
#[cfg(feature = "std")]
2729
pub use super::rootdevs::{DeviceEntry, RootDevs};
2830

@@ -342,36 +344,6 @@ pub struct SetAttrs {
342344
pub ctime: Option<u32>,
343345
}
344346

345-
/// Filesystem-level capacity stats returned by [`Filesystem::statfs`].
346-
/// All `u64` so backends with huge counts don't overflow. `name_max`
347-
/// is the longest filename the FS will accept.
348-
#[derive(Debug, Clone, Copy)]
349-
pub struct StatFs {
350-
pub block_size: u32,
351-
pub blocks: u64,
352-
pub blocks_free: u64,
353-
pub blocks_avail: u64,
354-
pub inodes: u64,
355-
pub inodes_free: u64,
356-
pub name_max: u32,
357-
}
358-
359-
impl Default for StatFs {
360-
fn default() -> Self {
361-
// 4 KiB block, no quota, generous name budget — the same
362-
// numbers the kernel hands out for tmpfs in a fresh mount.
363-
Self {
364-
block_size: 4096,
365-
blocks: 0,
366-
blocks_free: 0,
367-
blocks_avail: 0,
368-
inodes: 0,
369-
inodes_free: 0,
370-
name_max: 255,
371-
}
372-
}
373-
}
374-
375347
/// A single extended attribute, returned by [`Filesystem::list_xattrs`].
376348
#[derive(Debug, Clone)]
377349
pub struct XattrPair {

src/fs/exfat/volume/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,23 @@ impl<D: SectorDriver, const SECTOR: usize> Volume<D, SECTOR> {
13081308
Ok(self.geom.cluster_count - self.used_clusters()?)
13091309
}
13101310

1311+
/// Capacity figures in `statfs` shape: clusters as the allocation unit,
1312+
/// free clusters counted from the allocation bitmap (so the same cost as
1313+
/// [`Self::free_clusters`]), no inodes, and [`MAX_NAME_LEN`] as
1314+
/// `name_max`.
1315+
pub fn statfs(&mut self) -> Result<crate::fs::StatFs, Error<D::Error>> {
1316+
let free = self.free_clusters()? as u64;
1317+
Ok(crate::fs::StatFs {
1318+
block_size: self.geom.cluster_bytes(),
1319+
blocks: self.geom.cluster_count as u64,
1320+
blocks_free: free,
1321+
blocks_avail: free,
1322+
inodes: 0,
1323+
inodes_free: 0,
1324+
name_max: MAX_NAME_LEN as u32,
1325+
})
1326+
}
1327+
13111328
/// Free space in bytes.
13121329
pub fn free_bytes(&mut self) -> Result<u64, Error<D::Error>> {
13131330
Ok(self.free_clusters()? as u64 * self.geom.cluster_bytes() as u64)

src/fs/fat/volume/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,22 @@ impl<D: SectorDriver, const SECTOR: usize> Volume<D, SECTOR> {
11651165
Ok(free)
11661166
}
11671167

1168+
/// Capacity figures in `statfs` shape: clusters as the allocation unit,
1169+
/// free clusters from [`Self::free_clusters`] (so the same cost), no
1170+
/// inodes, and long-name entries' 255-unit limit as `name_max`.
1171+
pub fn statfs(&mut self) -> Result<crate::fs::StatFs, Error<D::Error>> {
1172+
let free = self.free_clusters()? as u64;
1173+
Ok(crate::fs::StatFs {
1174+
block_size: self.cluster_bytes(),
1175+
blocks: self.geom.cluster_count as u64,
1176+
blocks_free: free,
1177+
blocks_avail: free,
1178+
inodes: 0,
1179+
inodes_free: 0,
1180+
name_max: 255,
1181+
})
1182+
}
1183+
11681184
/// Free space in bytes, from [`Self::free_clusters`].
11691185
pub fn free_bytes(&mut self) -> Result<u64, Error<D::Error>> {
11701186
Ok(self.free_clusters()? as u64 * self.geom.cluster_bytes() as u64)

src/fs/littlefs/volume/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,6 +1528,23 @@ impl<D: FlashDriver, const BLOCK: usize, const PROG: usize> Volume<D, BLOCK, PRO
15281528
Ok(total)
15291529
}
15301530

1531+
/// Capacity figures in `statfs` shape: erase blocks as the allocation
1532+
/// unit, free blocks from [`Self::free_blocks`] (so a traversal, or the
1533+
/// in-use bitmap with `alloc`), no inodes, and the superblock's
1534+
/// `name_max`.
1535+
pub fn statfs(&mut self) -> Result<crate::fs::StatFs, Error<D::Error>> {
1536+
let free = self.free_blocks()? as u64;
1537+
Ok(crate::fs::StatFs {
1538+
block_size: self.geom.block_size,
1539+
blocks: self.geom.block_count as u64,
1540+
blocks_free: free,
1541+
blocks_avail: free,
1542+
inodes: 0,
1543+
inodes_free: 0,
1544+
name_max: self.geom.name_max,
1545+
})
1546+
}
1547+
15311548
/// Blocks the filesystem has left.
15321549
pub fn free_blocks(&mut self) -> Result<u32, Error<D::Error>> {
15331550
let used = self.used_blocks()?;

src/fs/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ mod api;
2121
#[cfg(feature = "alloc")]
2222
pub use api::*;
2323

24+
// Capacity figures, answered by both halves: plain data, so compiled in every
25+
// configuration.
26+
mod statfs;
27+
pub use statfs::StatFs;
28+
2429
#[cfg(all(feature = "alloc", feature = "affs"))]
2530
pub mod affs;
2631
#[cfg(all(feature = "alloc", feature = "apfs"))]

src/fs/statfs.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Capacity figures for a mounted filesystem, shaped like POSIX `statfs`.
2+
//!
3+
//! It is plain data — no allocation, no device, no feature — so it is
4+
//! compiled in every configuration and answered by both halves of the
5+
//! crate: the hosted [`Filesystem::statfs`](crate::fs::Filesystem::statfs)
6+
//! and, without a heap, [`Volume::statfs`](crate::fs::volume::Volume::statfs)
7+
//! on the FAT, exFAT and littlefs drivers.
8+
9+
/// Filesystem-level capacity stats.
10+
///
11+
/// Counts are in allocation units of `block_size` bytes — clusters on FAT
12+
/// and exFAT, erase blocks on littlefs, filesystem blocks elsewhere — and
13+
/// are all `u64` so large volumes do not overflow. `name_max` is the longest
14+
/// filename the filesystem accepts. A filesystem with no inode table (FAT,
15+
/// exFAT, littlefs) reports 0 for both inode counts.
16+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17+
pub struct StatFs {
18+
/// Bytes in one allocation unit.
19+
pub block_size: u32,
20+
/// Allocation units holding data, in all.
21+
pub blocks: u64,
22+
/// Allocation units not in use.
23+
pub blocks_free: u64,
24+
/// Allocation units an unprivileged writer may use: `blocks_free` on
25+
/// filesystems with no reserve.
26+
pub blocks_avail: u64,
27+
/// Inodes in all, or 0 when the filesystem has no inode table.
28+
pub inodes: u64,
29+
/// Inodes free.
30+
pub inodes_free: u64,
31+
/// Longest filename accepted.
32+
pub name_max: u32,
33+
}
34+
35+
impl StatFs {
36+
/// Bytes of data the filesystem can hold in all.
37+
pub fn total_bytes(&self) -> u64 {
38+
self.blocks.saturating_mul(self.block_size as u64)
39+
}
40+
41+
/// Bytes not in use.
42+
pub fn free_bytes(&self) -> u64 {
43+
self.blocks_free.saturating_mul(self.block_size as u64)
44+
}
45+
46+
/// Bytes an unprivileged writer may still use.
47+
pub fn avail_bytes(&self) -> u64 {
48+
self.blocks_avail.saturating_mul(self.block_size as u64)
49+
}
50+
}
51+
52+
impl Default for StatFs {
53+
fn default() -> Self {
54+
// 4 KiB block, no quota, generous name budget — the same
55+
// numbers the kernel hands out for tmpfs in a fresh mount.
56+
Self {
57+
block_size: 4096,
58+
blocks: 0,
59+
blocks_free: 0,
60+
blocks_avail: 0,
61+
inodes: 0,
62+
inodes_free: 0,
63+
name_max: 255,
64+
}
65+
}
66+
}

src/fs/volume/any.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ impl<D: SectorDriver, const SECTOR: usize, const BLOCK: usize> Volume
625625
each!(self, |v| Volume::free_bytes(v))
626626
}
627627

628+
fn statfs(&mut self) -> Result<crate::fs::StatFs, Self::Error> {
629+
each!(self, |v| Volume::statfs(v))
630+
}
631+
628632
fn unmount(self) -> Result<D, Self::Error> {
629633
match self {
630634
#[cfg(feature = "fat")]

src/fs/volume/exfat.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ impl<D: SectorDriver, const S: usize> Volume for Exfat<D, S> {
8787
fn free_bytes(&mut self) -> Result<u64, Self::Error> {
8888
Exfat::free_bytes(self)
8989
}
90+
fn statfs(&mut self) -> Result<crate::fs::StatFs, Self::Error> {
91+
Exfat::statfs(self)
92+
}
9093
fn unmount(self) -> Result<D, Self::Error> {
9194
Exfat::unmount(self)
9295
}

0 commit comments

Comments
 (0)