Skip to content

Commit 3454656

Browse files
committed
Bugfix: Use APFS-aware space check for copy validation
- `validate_disk_space` and `get_space_info_for_path` were using `statvfs` which reports only physically free blocks, ignoring purgeable space (APFS snapshots, iCloud caches) - The status bar already used `NSURLVolumeAvailableCapacityForImportantUsageKey` (matching Finder), so the copy error dialog showed ~67 GB while the bar showed ~100 GB - Both now call `crate::volumes::get_volume_space()` on macOS, falling back to `statvfs` on Linux - Documented the gotcha in `write_operations/CLAUDE.md` and `volume/CLAUDE.md`
1 parent 7333cb2 commit 3454656

4 files changed

Lines changed: 85 additions & 28 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ provides a manual upgrade path.
145145
**Decision**: Progress callbacks use `&dyn Fn(u64, u64) -> ControlFlow<()>`, not `FnMut`
146146
**Why**: The Volume trait is object-safe (`dyn Volume`), so callbacks must be `Fn` (not `FnMut`). Callers use `AtomicU64` for byte counters and `Cell<Instant>` for timestamps to mutate state inside a `Fn` closure. This avoids needing `RefCell` or `Mutex` in the hot path.
147147

148+
**Gotcha**: On macOS, never use `statvfs` alone for disk space — use `NSURLVolumeAvailableCapacityForImportantUsageKey`
149+
**Why**: `statvfs` reports only physically free blocks and ignores purgeable space (APFS snapshots, iCloud caches), which can be tens of GB. This causes inconsistent numbers between the status bar (NSURL API) and copy validation (`statvfs`), and prematurely blocks copies that would succeed. `get_space_info_for_path` calls `crate::volumes::get_volume_space()` on macOS and falls back to `statvfs` on Linux.
150+
148151
## Testing
149152

150153
- `in_memory_test.rs` — unit tests for `InMemoryVolume` (CRUD, sorting, concurrency, stress 50k entries)

apps/desktop/src-tauri/src/file_system/volume/local_posix.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,33 @@ fn copy_recursive(source: &Path, dest: &Path) -> Result<u64, VolumeError> {
319319
Ok(total_bytes)
320320
}
321321

322-
/// Gets space information for a path using statvfs.
322+
/// Gets space information for a path.
323+
///
324+
/// On macOS, uses `NSURLVolumeAvailableCapacityForImportantUsageKey` which includes purgeable
325+
/// space (APFS snapshots, iCloud caches) — matching what Finder reports. Falls back to `statvfs`
326+
/// if the NSURL query fails. On Linux, uses `statvfs` directly (no purgeable space concept).
323327
fn get_space_info_for_path(path: &Path) -> Result<SpaceInfo, VolumeError> {
328+
// On macOS, prefer the NSURL API that accounts for purgeable space.
329+
#[cfg(target_os = "macos")]
330+
{
331+
if let Some(space) = crate::volumes::get_volume_space(&path.to_string_lossy()) {
332+
// NSURL doesn't give us used_bytes directly, compute from total - available.
333+
let used_bytes = space.total_bytes.saturating_sub(space.available_bytes);
334+
return Ok(SpaceInfo {
335+
total_bytes: space.total_bytes,
336+
available_bytes: space.available_bytes,
337+
used_bytes,
338+
});
339+
}
340+
}
341+
342+
// Fallback (and Linux primary path): statvfs
343+
get_space_info_statvfs(path)
344+
}
345+
346+
/// Gets space information using `statvfs`. Used as the primary method on Linux and as a
347+
/// fallback on macOS.
348+
fn get_space_info_statvfs(path: &Path) -> Result<SpaceInfo, VolumeError> {
324349
use std::ffi::CString;
325350

326351
let path_c = CString::new(path.to_string_lossy().as_bytes()).map_err(|e| VolumeError::IoError(e.to_string()))?;

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ permissions. The only metadata it doesn't preserve is birthtime (creation date)
188188
matter only on same-volume copies where we use `copyfile` anyway. Detection uses `st_dev` (device ID) for same-volume
189189
and `statfs.f_fstypename` for APFS. See `copy_strategy.rs` for the implementation.
190190

191+
## Gotchas
192+
193+
**Gotcha**: On macOS, never use `statvfs` alone for disk space checks — use `NSURLVolumeAvailableCapacityForImportantUsageKey`
194+
**Why**: `statvfs` reports only physically free blocks. On APFS, purgeable space (iCloud caches, APFS snapshots) can account for tens of GB that macOS will reclaim on demand. Using `statvfs` causes the "insufficient space" error to reject copies that would actually succeed, and shows a different available-space number than the status bar (which uses the NSURL API). `validate_disk_space` in `helpers.rs` calls `crate::volumes::get_volume_space()` on macOS and falls back to `statvfs` on Linux.
195+
191196
## Dependencies
192197

193198
- `crate::file_system::volume``Volume` trait, `SpaceInfo`, `ScanConflict` (used by `volume_copy`)

apps/desktop/src-tauri/src/file_system/write_operations/helpers.rs

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -113,37 +113,19 @@ pub(crate) fn validate_destination_writable(_destination: &Path) -> Result<(), W
113113
}
114114

115115
/// Checks available disk space on the destination volume against required bytes.
116-
/// Uses statvfs on Unix to query free space.
116+
///
117+
/// On macOS, uses `NSURLVolumeAvailableCapacityForImportantUsageKey` which includes purgeable
118+
/// space (APFS snapshots, iCloud caches) — matching what Finder reports. Falls back to `statvfs`
119+
/// if the NSURL query fails. On Linux, uses `statvfs` directly (no purgeable space concept).
117120
#[cfg(unix)]
118121
pub(crate) fn validate_disk_space(destination: &Path, required_bytes: u64) -> Result<(), WriteOperationError> {
119-
use std::ffi::CString;
120-
use std::mem::MaybeUninit;
121-
use std::os::unix::ffi::OsStrExt;
122-
123-
let c_path = CString::new(destination.as_os_str().as_bytes()).map_err(|_| WriteOperationError::IoError {
124-
path: destination.display().to_string(),
125-
message: "Invalid path".to_string(),
126-
})?;
127-
128-
let mut stat = MaybeUninit::<libc::statvfs>::uninit();
129-
// SAFETY: c_path is a valid null-terminated C string, stat is a valid pointer
130-
let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
131-
if result != 0 {
132-
// Cannot determine space — continue and let the OS report ENOSPC if it happens
133-
return Ok(());
134-
}
135-
136-
// SAFETY: statvfs succeeded, stat is initialized
137-
let stat = unsafe { stat.assume_init() };
138-
// These casts are needed on macOS where f_bavail/f_frsize may not be u64
139-
#[allow(
140-
clippy::unnecessary_cast,
141-
reason = "Required for macOS where statvfs fields are not u64"
142-
)]
143-
let available = stat.f_bavail as u64 * stat.f_frsize as u64;
122+
let available = get_available_space(destination).unwrap_or({
123+
// Cannot determine space — return u64::MAX so the check passes and we let the OS
124+
// report ENOSPC if it actually happens during the copy.
125+
u64::MAX
126+
});
144127

145128
if required_bytes > available {
146-
// Determine volume name from mount point for a friendlier message
147129
let volume_name = destination
148130
.ancestors()
149131
.find(|p| p.parent().is_some_and(|pp| pp == Path::new("/Volumes")))
@@ -160,6 +142,48 @@ pub(crate) fn validate_disk_space(destination: &Path, required_bytes: u64) -> Re
160142
Ok(())
161143
}
162144

145+
/// Returns available bytes for a path, using the best API for the platform.
146+
///
147+
/// macOS: `NSURLVolumeAvailableCapacityForImportantUsageKey` (includes purgeable space).
148+
/// Linux: `statvfs` `f_bavail * f_frsize`.
149+
#[cfg(unix)]
150+
fn get_available_space(path: &Path) -> Option<u64> {
151+
// On macOS, prefer the NSURL API that accounts for purgeable space.
152+
#[cfg(target_os = "macos")]
153+
{
154+
if let Some(space) = crate::volumes::get_volume_space(&path.to_string_lossy()) {
155+
return Some(space.available_bytes);
156+
}
157+
}
158+
159+
// Fallback (and Linux primary path): statvfs
160+
get_available_space_statvfs(path)
161+
}
162+
163+
/// Returns available bytes using `statvfs`. Used as the primary method on Linux and as a
164+
/// fallback on macOS.
165+
#[cfg(unix)]
166+
fn get_available_space_statvfs(path: &Path) -> Option<u64> {
167+
use std::ffi::CString;
168+
use std::mem::MaybeUninit;
169+
use std::os::unix::ffi::OsStrExt;
170+
171+
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
172+
let mut stat = MaybeUninit::<libc::statvfs>::uninit();
173+
// SAFETY: c_path is a valid null-terminated C string, stat is a valid pointer
174+
let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
175+
if result != 0 {
176+
return None;
177+
}
178+
// SAFETY: statvfs succeeded, stat is initialized
179+
let stat = unsafe { stat.assume_init() };
180+
#[allow(
181+
clippy::unnecessary_cast,
182+
reason = "Required for macOS where statvfs fields are not u64"
183+
)]
184+
Some(stat.f_bavail as u64 * stat.f_frsize as u64)
185+
}
186+
163187
#[cfg(not(unix))]
164188
pub(crate) fn validate_disk_space(_destination: &Path, _required_bytes: u64) -> Result<(), WriteOperationError> {
165189
Ok(())

0 commit comments

Comments
 (0)