|
| 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 | +} |
0 commit comments