1use std::fmt;
6use std::path::Path;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum FileSystem {
10 Fat,
12 ExFat,
13 HfsPlus,
14 Apfs,
15 Ntfs,
16 Other(String),
18}
19
20impl fmt::Display for FileSystem {
21 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
22 f.write_str(match self {
23 FileSystem::Fat => "FAT",
24 FileSystem::ExFat => "exFAT",
25 FileSystem::HfsPlus => "HFS+",
26 FileSystem::Apfs => "APFS",
27 FileSystem::Ntfs => "NTFS",
28 FileSystem::Other(name) => name,
29 })
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PartitionTable {
35 Mbr,
36 Gpt,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum FormatWarning {
47 FileSystem(FileSystem),
48 Gpt,
49}
50
51impl fmt::Display for FormatWarning {
52 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
53 match self {
54 FormatWarning::FileSystem(FileSystem::ExFat) => f.write_str(
55 "This stick is exFAT. A CDJ-3000 reads it (AlphaTheta lists exFAT for the CDJ-3000, XDJ-RX3 and XDJ-XZ); a CDJ-2000NXS2 and older do not, their manuals list FAT16, FAT32 and HFS+ only.",
56 ),
57 FormatWarning::FileSystem(FileSystem::Ntfs) => f.write_str(
58 "This stick is NTFS, which the players' manuals rule out (\"NTFS is not supported\"). Format it as FAT32.",
59 ),
60 FormatWarning::FileSystem(fs) => write!(
61 f,
62 "This stick is {fs}, which no player's manual lists (FAT16, FAT32 and HFS+, plus exFAT on a CDJ-3000). Format it as FAT32."
63 ),
64 FormatWarning::Gpt => f.write_str(
65 "This stick has a GUID (GPT) partition table. AlphaTheta says the CDJ-3000 and the CDJ-2000 do not support it, and a CDJ-2000NXS2 is reported not to mount it either. Repartition it with an MBR (Master Boot Record) table.",
66 ),
67 }
68 }
69}
70
71pub fn warnings(fs: Option<&FileSystem>, table: Option<PartitionTable>) -> Vec<FormatWarning> {
74 let mut out = Vec::new();
75 if let Some(fs) = fs.filter(|fs| !matches!(fs, FileSystem::Fat | FileSystem::HfsPlus)) {
76 out.push(FormatWarning::FileSystem(fs.clone()));
77 }
78 if table == Some(PartitionTable::Gpt) {
79 out.push(FormatWarning::Gpt);
80 }
81 out
82}
83
84pub fn probe(dir: &Path) -> (Option<FileSystem>, Option<PartitionTable>) {
88 imp::probe(dir)
89}
90
91#[cfg(target_os = "macos")]
92mod imp {
93 use super::{FileSystem, PartitionTable};
94 use std::ffi::CString;
95 use std::os::unix::ffi::OsStrExt;
96 use std::path::Path;
97 use std::process::Command;
98
99 pub fn probe(dir: &Path) -> (Option<FileSystem>, Option<PartitionTable>) {
100 let Some(st) = statfs(dir) else {
101 return (None, None);
102 };
103 let fs = match c_str(&st.f_fstypename).as_str() {
104 "msdos" => FileSystem::Fat,
105 "exfat" => FileSystem::ExFat,
106 "hfs" => FileSystem::HfsPlus,
107 "apfs" => FileSystem::Apfs,
108 "ntfs" => FileSystem::Ntfs,
109 other => FileSystem::Other(other.to_string()),
110 };
111 let table = whole_disk(&c_str(&st.f_mntfromname)).and_then(partition_scheme);
112 (Some(fs), table)
113 }
114
115 fn statfs(dir: &Path) -> Option<libc::statfs> {
116 let path = CString::new(dir.as_os_str().as_bytes()).ok()?;
117 let mut st = std::mem::MaybeUninit::<libc::statfs>::uninit();
118 (unsafe { libc::statfs(path.as_ptr(), st.as_mut_ptr()) } == 0)
120 .then(|| unsafe { st.assume_init() })
121 }
122
123 fn c_str(chars: &[libc::c_char]) -> String {
124 let bytes: Vec<u8> = chars
125 .iter()
126 .take_while(|&&c| c != 0)
127 .map(|&c| c as u8)
128 .collect();
129 String::from_utf8_lossy(&bytes).into_owned()
130 }
131
132 pub(super) fn whole_disk(dev: &str) -> Option<&str> {
134 let name = dev.strip_prefix("/dev/")?;
135 let digits = name
136 .strip_prefix("disk")?
137 .bytes()
138 .take_while(u8::is_ascii_digit)
139 .count();
140 (digits > 0).then(|| &name[..4 + digits])
141 }
142
143 fn partition_scheme(disk: &str) -> Option<PartitionTable> {
146 let out = Command::new("diskutil")
147 .args(["info", "-plist", disk])
148 .output()
149 .ok()?;
150 if !out.status.success() {
151 return None;
152 }
153 match plist_string(&String::from_utf8_lossy(&out.stdout), "Content")? {
154 "FDisk_partition_scheme" => Some(PartitionTable::Mbr),
155 "GUID_partition_scheme" => Some(PartitionTable::Gpt),
156 _ => None,
157 }
158 }
159
160 pub(super) fn plist_string<'a>(plist: &'a str, key: &str) -> Option<&'a str> {
161 let rest = &plist[plist.find(&format!("<key>{key}</key>"))?..];
162 let start = rest.find("<string>")? + "<string>".len();
163 let len = rest[start..].find("</string>")?;
164 Some(&rest[start..start + len])
165 }
166}
167
168#[cfg(target_os = "linux")]
169mod imp {
170 use super::{FileSystem, PartitionTable};
171 use std::os::unix::fs::MetadataExt;
172 use std::path::Path;
173 use std::process::Command;
174
175 pub fn probe(dir: &Path) -> (Option<FileSystem>, Option<PartitionTable>) {
176 let Ok(dev) = std::fs::metadata(dir).map(|m| m.dev()) else {
177 return (None, None);
178 };
179 let id = format!("{}:{}", libc::major(dev), libc::minor(dev));
180 let Some((fstype, source)) = std::fs::read_to_string("/proc/self/mountinfo")
181 .ok()
182 .and_then(|m| mount_of(&m, &id))
183 else {
184 return (None, None);
185 };
186 let fs = match fstype.as_str() {
187 "vfat" | "msdos" => Some(FileSystem::Fat),
188 "exfat" => Some(FileSystem::ExFat),
189 "hfsplus" => Some(FileSystem::HfsPlus),
190 "ntfs" | "ntfs3" => Some(FileSystem::Ntfs),
191 "fuseblk" => None,
193 other => Some(FileSystem::Other(other.to_string())),
194 };
195 (fs, partition_table(&source))
196 }
197
198 pub(super) fn mount_of(mountinfo: &str, id: &str) -> Option<(String, String)> {
200 mountinfo.lines().find_map(|line| {
201 let mut fields = line.split_whitespace();
202 if fields.nth(2)? != id {
203 return None;
204 }
205 let mut rest = fields.skip_while(|f| *f != "-").skip(1);
206 Some((rest.next()?.to_string(), rest.next()?.to_string()))
207 })
208 }
209
210 fn partition_table(device: &str) -> Option<PartitionTable> {
212 if !device.starts_with("/dev/") {
213 return None;
214 }
215 let out = Command::new("lsblk")
216 .args(["-no", "PTTYPE", device])
217 .output()
218 .ok()?;
219 match String::from_utf8_lossy(&out.stdout).trim() {
220 "dos" => Some(PartitionTable::Mbr),
221 "gpt" => Some(PartitionTable::Gpt),
222 _ => None,
223 }
224 }
225}
226
227#[cfg(not(any(target_os = "macos", target_os = "linux")))]
229mod imp {
230 use super::{FileSystem, PartitionTable};
231 use std::path::Path;
232
233 pub fn probe(_: &Path) -> (Option<FileSystem>, Option<PartitionTable>) {
234 (None, None)
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn fat_and_hfs_plus_on_mbr_are_fine_and_unknowns_say_nothing() {
244 assert!(warnings(Some(&FileSystem::Fat), Some(PartitionTable::Mbr)).is_empty());
245 assert!(warnings(Some(&FileSystem::HfsPlus), Some(PartitionTable::Mbr)).is_empty());
246 assert!(warnings(Some(&FileSystem::Fat), None).is_empty());
247 assert!(warnings(None, None).is_empty());
248 assert_eq!(
249 warnings(Some(&FileSystem::Apfs), None),
250 [FormatWarning::FileSystem(FileSystem::Apfs)]
251 );
252 }
253
254 #[test]
255 fn exfat_on_gpt_gets_both_warnings() {
256 assert_eq!(
257 warnings(Some(&FileSystem::ExFat), Some(PartitionTable::Gpt)),
258 [
259 FormatWarning::FileSystem(FileSystem::ExFat),
260 FormatWarning::Gpt
261 ]
262 );
263 }
264
265 #[cfg(target_os = "macos")]
266 #[test]
267 fn whole_disk_and_plist_parsing() {
268 assert_eq!(imp::whole_disk("/dev/disk4s1"), Some("disk4"));
269 assert_eq!(imp::whole_disk("/dev/disk12"), Some("disk12"));
270 assert_eq!(imp::whole_disk("map auto_home"), None);
271 assert_eq!(imp::whole_disk("/dev/diskX"), None);
272 let plist = "<dict>\n\t<key>Bootable</key>\n\t<false/>\n\t<key>Content</key>\n\t<string>GUID_partition_scheme</string>\n</dict>";
273 assert_eq!(
274 imp::plist_string(plist, "Content"),
275 Some("GUID_partition_scheme")
276 );
277 assert_eq!(imp::plist_string(plist, "Missing"), None);
278 }
279
280 #[cfg(target_os = "linux")]
281 #[test]
282 fn mountinfo_lookup() {
283 let m = "22 1 259:2 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw\n\
284 91 29 8:17 / /media/dj/USB rw,nosuid shared:50 master:2 - vfat /dev/sdb1 rw,fmask=0022\n";
285 assert_eq!(
286 imp::mount_of(m, "8:17"),
287 Some(("vfat".into(), "/dev/sdb1".into()))
288 );
289 assert_eq!(imp::mount_of(m, "8:1"), None);
290 }
291}