Skip to main content

baken_export/
volume.rs

1//! What the stick is formatted as (issue #184). A player that cannot mount a
2//! stick shows no library at all, which looks exactly like a broken export, so
3//! `plan` says so before the first file is copied.
4
5use std::fmt;
6use std::path::Path;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum FileSystem {
10    /// FAT12, FAT16 or FAT32: the filesystem type the OS reports does not tell them apart.
11    Fat,
12    ExFat,
13    HfsPlus,
14    Apfs,
15    Ntfs,
16    /// Anything else, by the name the OS gives it.
17    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/// A stick format that rules out some players. The players' operating
40/// instructions (CDJ-3000 DRI1586-A p.11, CDJ-2000NXS2 DRI1290-A p.6, and
41/// the CDJ-2000NXS, CDJ-900NXS, CDJ-2000, CDJ-900 and CDJ-850 alike) list
42/// FAT16, FAT32 and HFS+ and say "NTFS is not supported". exFAT and GPT come
43/// from AlphaTheta's support pages: exFAT for the CDJ-3000, XDJ-RX3 and
44/// XDJ-XZ only, and no GUID partition map on the CDJ-3000 or the CDJ-2000.
45#[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
71/// Warnings for a stick with this filesystem and partition table; nothing for
72/// what could not be read.
73pub 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
84/// Filesystem and partition table of the volume `dir` is on, as far as this
85/// platform can tell. The partition table belongs to the whole disk rather
86/// than the volume, so it is best effort: `None` whenever it cannot be read.
87pub 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        // SAFETY: `path` is NUL-terminated and statfs fills `st` whenever it returns 0.
119        (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    /// `/dev/disk4s1` → `disk4`.
133    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    /// The whole disk's content type from `diskutil`, which a sandboxed app
144    /// may not be allowed to run; the caller can fill this in itself then.
145    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            // exfat-fuse and ntfs-3g both mount as `fuseblk`.
192            "fuseblk" => None,
193            other => Some(FileSystem::Other(other.to_string())),
194        };
195        (fs, partition_table(&source))
196    }
197
198    /// Filesystem type and source of the mount whose device is `id` (`major:minor`).
199    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    /// `PTTYPE` of the table `device` (a partition such as `/dev/sdb1`) sits in.
211    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/// Not read on Windows yet.
228#[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}