Skip to content

Commit f177b60

Browse files
committed
Block copying or moving files too large for the destination filesystem (FAT32's 4 GB cap)
A FAT32 USB stick silently failed a 5 GB copy partway through (~4 GB in) because nothing checked the destination's per-file limit. Now copy and cross-filesystem move pre-flight that limit during the scan and show a clear, all-or-nothing dialog before writing a byte — even when the offending file is buried deep under one of several selected folders. - New `file_system::filesystem_kind` module: classifies the destination (`FilesystemKind`) and derives its per-file cap (`MaxFileSize`). Only FAT32 (`msdos`/`vfat`) is `Limited` (4 GiB − 1); exFAT, NTFS, APFS, ext4, and MTP are `Unlimited`; OS-mounted SMB and unrecognized formats are `Unknown`. The kind → cap map is the single source of truth. - The guard blocks ONLY when certain. `Unknown`/`Unlimited` never block, so it can't raise a false alarm (a false block is worse than the old mid-copy failure — it would stop a copy that would have succeeded). exFAT, the common big-USB format, deliberately stays uncapped. - Detection via macOS `statfs.f_fstypename` / Linux `/proc/mounts`. The gate (`validate_file_sizes_for_filesystem`) runs alongside the free-space check in the local-FS copy path and the cross-FS move-staging path; same-FS moves rename in place and skip it. - New `WriteOperationError::FilesTooLargeForFilesystem` variant carries up to 10 offenders (name + size, largest first) plus the true total; the error dialog lists them with an "and N more" line and names FAT32 in plain language. - Follow-ups (scoped, not in this change): SMB backing-filesystem detection (a `smb2`-crate `FileFsAttributeInformation` query), the volume-picker filesystem display, and translating the 6 new strings across the non-English locales.
1 parent dbceea7 commit f177b60

15 files changed

Lines changed: 606 additions & 2 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
//! Filesystem identity and the per-file size limit derived from it.
2+
//!
3+
//! Two deliberately separate axes:
4+
//! - [`FilesystemKind`]: WHAT the destination filesystem is (a fact). Drives the
5+
//! volume-picker label and any logic that keys off the format.
6+
//! - [`MaxFileSize`]: whether the filesystem caps per-file size, derived purely
7+
//! from the kind. The oversized-file write guard reads this; `Unknown` and
8+
//! `Unlimited` never block, only `Limited` does.
9+
//!
10+
//! The kind → limit map (`FilesystemKind::max_file_size`) is the single source
11+
//! of truth: the write guard, the volume DTO, and any future caller all read it.
12+
//!
13+
//! The pure logic (the enums, `from_raw_type`, the map) has no platform deps and
14+
//! is unit-tested directly. `detect_filesystem_for_path` is the thin
15+
//! platform-specific wiring that resolves a path to its OS filesystem-type
16+
//! string and feeds it in.
17+
18+
use serde::{Deserialize, Serialize};
19+
20+
/// FAT32's hard per-file ceiling: 4 GiB minus one byte, the largest value a
21+
/// 32-bit byte-count field can hold. A file of exactly 4 GiB does not fit.
22+
pub const FAT32_MAX_FILE_SIZE: u64 = u32::MAX as u64; // 4_294_967_295
23+
24+
/// What a destination filesystem is. A factual classification used for the
25+
/// volume-picker label and to derive the per-file size limit. `Other` carries
26+
/// no detail of its own; the raw OS type string travels alongside in
27+
/// [`FilesystemInfo::raw_type`].
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
29+
#[serde(rename_all = "snake_case")]
30+
pub enum FilesystemKind {
31+
Apfs,
32+
HfsPlus,
33+
Ext4,
34+
Btrfs,
35+
Xfs,
36+
Zfs,
37+
Ntfs,
38+
ExFat,
39+
/// FAT32 (and FAT16): the only common format with a hard 4 GiB per-file cap.
40+
Fat32,
41+
/// SMB share whose backing filesystem we can't see (an OS-mounted
42+
/// `smbfs`/`cifs` mount). Direct smb2 sessions could later resolve the real
43+
/// backing format; until then this stays `Unknown` for the size guard.
44+
Smb,
45+
/// MTP device. No queryable POSIX filesystem; large files stream fine.
46+
Mtp,
47+
/// Recognized nothing. The raw type string is kept in `FilesystemInfo` for
48+
/// display and diagnostics.
49+
Other,
50+
}
51+
52+
impl FilesystemKind {
53+
/// Classifies a raw filesystem-type string from macOS `statfs.f_fstypename`,
54+
/// Linux `/proc/mounts`, or an SMB server's reported `FileSystemName`.
55+
/// Case-insensitive. Unrecognized strings become [`FilesystemKind::Other`].
56+
pub fn from_raw_type(raw: &str) -> Self {
57+
match raw.to_ascii_lowercase().as_str() {
58+
"apfs" => Self::Apfs,
59+
"hfs" | "hfsplus" | "hfs+" => Self::HfsPlus,
60+
"ext4" | "ext3" | "ext2" => Self::Ext4,
61+
"btrfs" => Self::Btrfs,
62+
"xfs" => Self::Xfs,
63+
"zfs" => Self::Zfs,
64+
"ntfs" | "ntfs3" | "ufsd_ntfs" => Self::Ntfs,
65+
"exfat" | "fuse.exfat" => Self::ExFat,
66+
// macOS reports both FAT16 and FAT32 as "msdos"; Linux as "vfat".
67+
// Both carry the 4 GiB cap, so the safe number covers either.
68+
"msdos" | "vfat" | "fat" | "fat32" | "fat16" => Self::Fat32,
69+
"smbfs" | "cifs" => Self::Smb,
70+
_ => Self::Other,
71+
}
72+
}
73+
74+
/// The largest single file this filesystem accepts. The single source of
75+
/// truth for the oversized-file write guard.
76+
///
77+
/// Only [`FilesystemKind::Fat32`] is `Limited`. Modern formats (and MTP) are
78+
/// `Unlimited`; formats we can't see through (`Smb`, `Other`) are `Unknown`,
79+
/// which the guard treats as "don't block" so it never raises a false alarm.
80+
pub fn max_file_size(self) -> MaxFileSize {
81+
match self {
82+
Self::Fat32 => MaxFileSize::Limited {
83+
bytes: FAT32_MAX_FILE_SIZE,
84+
},
85+
Self::Apfs
86+
| Self::HfsPlus
87+
| Self::Ext4
88+
| Self::Btrfs
89+
| Self::Xfs
90+
| Self::Zfs
91+
| Self::Ntfs
92+
| Self::ExFat
93+
| Self::Mtp => MaxFileSize::Unlimited,
94+
Self::Smb | Self::Other => MaxFileSize::Unknown,
95+
}
96+
}
97+
}
98+
99+
/// The largest single file a filesystem accepts. Derived from [`FilesystemKind`].
100+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
101+
#[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
102+
pub enum MaxFileSize {
103+
/// A hard per-file ceiling in bytes (FAT32). A larger file is rejected.
104+
Limited { bytes: u64 },
105+
/// No practical per-file limit (APFS, ext4, NTFS, exFAT, MTP, ...).
106+
Unlimited,
107+
/// The limit couldn't be determined. The write guard treats this as
108+
/// "don't block".
109+
Unknown,
110+
}
111+
112+
/// A destination filesystem's identity plus the bits the frontend needs to show
113+
/// it. Rides on the volume DTO (volume picker) and on the oversized-file error.
114+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
115+
#[serde(rename_all = "camelCase")]
116+
pub struct FilesystemInfo {
117+
pub kind: FilesystemKind,
118+
/// Raw type string as reported by the OS (`statfs`/mounts) or an SMB server,
119+
/// for display fallback on [`FilesystemKind::Other`] and for diagnostics.
120+
pub raw_type: Option<String>,
121+
/// The largest single file this filesystem accepts, in bytes. `None` means
122+
/// no known limit (or unknown), so the picker can show "max 4 GB per file"
123+
/// only when a real cap exists.
124+
pub max_file_size_bytes: Option<u64>,
125+
}
126+
127+
impl FilesystemInfo {
128+
/// Builds from a known kind plus the raw type string it came from.
129+
pub fn new(kind: FilesystemKind, raw_type: Option<String>) -> Self {
130+
let max_file_size_bytes = match kind.max_file_size() {
131+
MaxFileSize::Limited { bytes } => Some(bytes),
132+
MaxFileSize::Unlimited | MaxFileSize::Unknown => None,
133+
};
134+
Self {
135+
kind,
136+
raw_type,
137+
max_file_size_bytes,
138+
}
139+
}
140+
141+
/// Builds from a raw OS filesystem-type string (the common path). `None`
142+
/// classifies as [`FilesystemKind::Other`].
143+
pub fn from_raw_type(raw_type: Option<String>) -> Self {
144+
let kind = raw_type
145+
.as_deref()
146+
.map(FilesystemKind::from_raw_type)
147+
.unwrap_or(FilesystemKind::Other);
148+
Self::new(kind, raw_type)
149+
}
150+
}
151+
152+
/// Detects the filesystem at `path` by resolving its mount and reading the OS
153+
/// filesystem-type string, then classifying it.
154+
///
155+
/// Returns [`FilesystemKind::Other`] / [`MaxFileSize::Unknown`] when the type
156+
/// can't be resolved, so the write guard never blocks on a guess.
157+
///
158+
/// macOS resolves via `statfs.f_fstypename`; other Unix via `/proc/mounts`.
159+
/// The single `statfs` is fast on local mounts (the only ones that reach the
160+
/// local-FS copy/move path); a hung network mount would already have stalled the
161+
/// preceding free-space query on the same destination.
162+
#[cfg(target_os = "macos")]
163+
pub fn detect_filesystem_for_path(path: &std::path::Path) -> FilesystemInfo {
164+
let raw = crate::volumes::get_mount_point(&path.to_string_lossy()).map(|(_, fs_type)| fs_type);
165+
FilesystemInfo::from_raw_type(raw)
166+
}
167+
168+
#[cfg(target_os = "linux")]
169+
pub fn detect_filesystem_for_path(path: &std::path::Path) -> FilesystemInfo {
170+
let raw = crate::file_system::linux_mounts::fs_type_for_path(path);
171+
FilesystemInfo::from_raw_type(raw)
172+
}
173+
174+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
175+
pub fn detect_filesystem_for_path(_path: &std::path::Path) -> FilesystemInfo {
176+
FilesystemInfo::from_raw_type(None)
177+
}
178+
179+
#[cfg(test)]
180+
mod tests {
181+
use super::*;
182+
183+
#[test]
184+
fn classifies_known_raw_types_case_insensitively() {
185+
assert_eq!(FilesystemKind::from_raw_type("apfs"), FilesystemKind::Apfs);
186+
assert_eq!(FilesystemKind::from_raw_type("APFS"), FilesystemKind::Apfs);
187+
assert_eq!(FilesystemKind::from_raw_type("msdos"), FilesystemKind::Fat32);
188+
assert_eq!(FilesystemKind::from_raw_type("vfat"), FilesystemKind::Fat32);
189+
assert_eq!(FilesystemKind::from_raw_type("exfat"), FilesystemKind::ExFat);
190+
assert_eq!(FilesystemKind::from_raw_type("ntfs"), FilesystemKind::Ntfs);
191+
assert_eq!(FilesystemKind::from_raw_type("smbfs"), FilesystemKind::Smb);
192+
assert_eq!(FilesystemKind::from_raw_type("cifs"), FilesystemKind::Smb);
193+
}
194+
195+
#[test]
196+
fn unrecognized_raw_type_is_other() {
197+
assert_eq!(FilesystemKind::from_raw_type("zalgofs"), FilesystemKind::Other);
198+
assert_eq!(FilesystemKind::from_raw_type(""), FilesystemKind::Other);
199+
}
200+
201+
#[test]
202+
fn only_fat_is_limited_exfat_is_not() {
203+
// The load-bearing distinction: exFAT (the common big-USB format) must
204+
// NOT be capped, only FAT32.
205+
assert_eq!(
206+
FilesystemKind::Fat32.max_file_size(),
207+
MaxFileSize::Limited {
208+
bytes: FAT32_MAX_FILE_SIZE
209+
}
210+
);
211+
assert_eq!(FilesystemKind::ExFat.max_file_size(), MaxFileSize::Unlimited);
212+
assert_eq!(FilesystemKind::Ntfs.max_file_size(), MaxFileSize::Unlimited);
213+
assert_eq!(FilesystemKind::Apfs.max_file_size(), MaxFileSize::Unlimited);
214+
assert_eq!(FilesystemKind::Mtp.max_file_size(), MaxFileSize::Unlimited);
215+
}
216+
217+
#[test]
218+
fn unseeable_filesystems_are_unknown_not_blocked() {
219+
assert_eq!(FilesystemKind::Smb.max_file_size(), MaxFileSize::Unknown);
220+
assert_eq!(FilesystemKind::Other.max_file_size(), MaxFileSize::Unknown);
221+
}
222+
223+
#[test]
224+
fn fat32_cap_is_four_gib_minus_one() {
225+
// A file of exactly 4 GiB must NOT fit; one byte under must.
226+
assert_eq!(FAT32_MAX_FILE_SIZE, 4 * 1024 * 1024 * 1024 - 1);
227+
}
228+
229+
#[test]
230+
fn info_exposes_cap_only_for_limited_filesystems() {
231+
assert_eq!(
232+
FilesystemInfo::from_raw_type(Some("msdos".to_string())).max_file_size_bytes,
233+
Some(FAT32_MAX_FILE_SIZE)
234+
);
235+
assert_eq!(
236+
FilesystemInfo::from_raw_type(Some("exfat".to_string())).max_file_size_bytes,
237+
None
238+
);
239+
assert_eq!(FilesystemInfo::from_raw_type(None).kind, FilesystemKind::Other);
240+
}
241+
}

apps/desktop/src-tauri/src/file_system/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! File system module - operations, watchers, volumes, and providers.
22
33
pub mod cloud_actions;
4+
pub mod filesystem_kind;
45
pub mod git;
56
#[cfg(target_os = "linux")]
67
pub(crate) mod linux_mounts;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ Drives "disable Eject while an op reads from / writes to this device" so a disco
243243

244244
## Key decisions (shared)
245245

246+
**Decision**: Copy and cross-FS move pre-flight a destination per-file-size limit (FAT32's 4 GiB cap) right after the scan, before the first byte. `validation::validate_file_sizes_for_filesystem` classifies the destination via `crate::file_system::filesystem_kind` (macOS `statfs.f_fstypename` / Linux `/proc/mounts``FilesystemKind``MaxFileSize`) and, only when the cap is `Limited`, fails the whole operation with `WriteOperationError::FilesTooLargeForFilesystem` (up to 10 offenders, largest first, plus the true count).
247+
**Why**: A FAT32 USB stick silently failed a 5 GB copy ~4 GB in. The gate is all-or-nothing and runs alongside the free-space check (`copy/mod.rs`, `move_op.rs::move_with_staging`). It blocks **only** when certain: `Unlimited` (APFS/exFAT/NTFS/ext4/MTP) and `Unknown` (OS-mounted SMB, unrecognized) never block, so a false positive — worse than the mid-copy failure because it stops a copy that would have succeeded — can't happen. **exFAT must stay `Unlimited`** (it's the common big-USB format with no 4 GiB cap); only FAT32 (`msdos`/`vfat`) is `Limited`. Same-FS moves rename in place and never reach the gate. The kind → cap map in `filesystem_kind::FilesystemKind::max_file_size` is the single source of truth (the write guard, the error prose, and any future volume-picker display all read it). SMB FileSystemName detection (a `smb2`-crate `FileFsAttributeInformation` query) and the volume-picker filesystem display are scoped follow-ups.
248+
246249
**Decision**: Every scan reports **two** byte totals — `total_bytes` (write footprint, un-dedup'd) and `dedup_bytes` (`du`-equivalent, each inode once). Delete consumes `dedup_bytes`; copy/move consume `total_bytes`; the Copy dialog shows both.
247250
**Why**: A hardlink contributes differently to the two operations. **Delete** frees an inode only when its last link is removed, so the bytes-freed number is the dedup'd one — counting every link would claim to free 80 GB when only 60 GB (cargo `target/`) actually frees. **Copy/move** materialize every hardlink as an independent file at the destination (hardlinks don't survive a cross-volume copy, and even a same-FS `cp` doesn't relink), so the bytes-written number — and the disk-space reservation — is the full write footprint. The earlier single-`total_bytes`-is-dedup'd design got delete right but silently regressed copy: the space check under-reserved (risking ENOSPC mid-copy) and the bar hit 100% early. Now `walk_dir_recursive` / `walk_cached_entries` / `scan_volume_recursive` / `LocalPosixVolume::scan_for_copy` / `scan_subtree_with_oracle` all track both, using a `seen_inodes: HashSet<u64>` (mirrors `indexing/scanner.rs`, `nlink == 1` fast path, operation-scoped across source roots; **Unix-only**, where non-Unix has no `nlink()` so `dedup_bytes == total_bytes`). Volume backends populate `FileEntry::inode` only for `LocalPosixVolume` files with `nlink > 1` (MTP/SMB/InMemory leave it `None`, so dedup is a no-op and the two totals are equal). The **scan-phase** progress bar reports the dedup'd running total (it's compared against the indexer's inode-dedup'd `dir_stats` estimate, so reporting the write footprint would overshoot 100% on hardlink trees). The **delete** active phase sums per-entry `progress_bytes`/`VolumeDeleteEntry::progress_bytes` (= dedup'd) against the `dedup_bytes` denominator. The **copy** active phase credits full per-file `size` against the `total_bytes` denominator (no chunk scaling). The Copy dialog surfaces the gap with a one-line note ("X will be written; source is Y; the extra is hardlinked files…") via `dedup_bytes_total` on the scan-preview events — copy-only, since a same-FS move writes nothing. Pinned by `delete/hardlink_progress_tests.rs`, `delete/volume_hardlink_progress_tests.rs`, `transfer/hardlink_progress_tests.rs::copy_counts_write_footprint_for_hardlinks`, `scan.rs::tests::walker_dedupes_*`, `local_posix_test::test_scan_for_copy_dedupes_hardlinks_for_source_size_only`, and `transfer-dialog-utils.test.ts::shouldShowHardlinkNote`.
248251

apps/desktop/src-tauri/src/file_system/write_operations/transfer/copy/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use super::super::types::{
2525
WriteOperationConfig, WriteOperationError, WriteOperationPhase, WriteOperationType, WriteProgressEvent,
2626
WriteSourceItemDoneEvent,
2727
};
28-
use super::super::validation::validate_disk_space;
28+
use super::super::validation::{validate_disk_space, validate_file_sizes_for_filesystem};
2929
use super::transfer_driver::{DriverConfig, PostLoopIntent, TransferOutcome, drive_transfer_serial_sync};
3030

3131
mod rollback;
@@ -184,6 +184,11 @@ pub(in crate::file_system::write_operations) fn copy_files_with_progress_inner(
184184
operation_id
185185
);
186186

187+
// Pre-flight filesystem-limit check: block before writing a byte if any file
188+
// is too large for the destination filesystem (FAT32's 4 GiB cap). No-op for
189+
// filesystems with no known limit.
190+
validate_file_sizes_for_filesystem(destination, &scan_result.files)?;
191+
187192
// Phase 2: Copy files in sorted order with rollback support
188193
let mut transaction = CopyTransaction::new();
189194
let mut apply_to_all_resolution = ApplyToAll::default();

apps/desktop/src-tauri/src/file_system/write_operations/transfer/move_op.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use super::super::types::{
1818
WriteOperationConfig, WriteOperationError, WriteOperationPhase, WriteOperationType, WriteProgressEvent,
1919
WriteSourceItemDoneEvent,
2020
};
21-
use super::super::validation::{is_same_filesystem, path_exists_or_is_symlink};
21+
use super::super::validation::{is_same_filesystem, path_exists_or_is_symlink, validate_file_sizes_for_filesystem};
2222
use super::copy::copy_single_item;
2323

2424
// ============================================================================
@@ -482,6 +482,12 @@ fn move_with_staging(
482482
)?
483483
};
484484

485+
// Pre-flight filesystem-limit check: a cross-FS move stages a full copy, so
486+
// the destination's per-file cap (FAT32's 4 GiB) applies. Block before
487+
// creating the staging dir or writing a byte. No-op for filesystems with no
488+
// known limit. (Same-FS moves rename in place and never reach here.)
489+
validate_file_sizes_for_filesystem(destination, &scan_result.files)?;
490+
485491
// Create staging directory
486492
let staging_dir = destination.join(format!(".cmdr-staging-{}", operation_id));
487493
fs::create_dir(&staging_dir).map_err(|e| WriteOperationError::IoError {

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,13 +429,37 @@ pub enum WriteOperationError {
429429
DeletePending {
430430
path: String,
431431
},
432+
/// One or more files exceed the destination filesystem's per-file size
433+
/// limit (FAT32's 4 GiB cap). Detected during the pre-copy scan, before any
434+
/// bytes are written, so the whole operation is blocked all-or-nothing
435+
/// rather than failing partway through.
436+
FilesTooLargeForFilesystem {
437+
/// The destination filesystem, so the message can name it ("FAT32").
438+
filesystem: crate::file_system::filesystem_kind::FilesystemKind,
439+
/// The per-file ceiling in bytes (FAT32: 4 GiB − 1).
440+
max_size: u64,
441+
/// Up to 10 offending files (name + size), largest first.
442+
files: Vec<OversizedFile>,
443+
/// Total number of offending files (may exceed `files.len()`).
444+
total_count: usize,
445+
},
432446
/// Catch-all for genuinely unexpected IO errors.
433447
IoError {
434448
path: String,
435449
message: String,
436450
},
437451
}
438452

453+
/// A file that exceeds the destination filesystem's per-file size limit.
454+
/// Carried by [`WriteOperationError::FilesTooLargeForFilesystem`] so the dialog
455+
/// can list the offenders.
456+
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
457+
#[serde(rename_all = "camelCase")]
458+
pub struct OversizedFile {
459+
pub name: String,
460+
pub size: u64,
461+
}
462+
439463
// ============================================================================
440464
// Result types
441465
// ============================================================================

0 commit comments

Comments
 (0)