aede_core/copy/names.rs
1//! Making a path acceptable to the filesystem it is about to be written to.
2//!
3//! A music library is full of names a general-purpose filesystem takes without
4//! blinking and a portable player refuses: `Where Is My Mind?`, `Symphony No. 5:
5//! Allegro`, `AC/DC`. Copying to a FAT32 or exFAT card — which is what a player
6//! or a cheap USB stick almost always is — fails on those, one file at a time,
7//! in the middle of a run that has already taken twenty minutes.
8//!
9//! **Nothing here is silent.** Every component this module changes is reported
10//! back so the run can say what it renamed and why. A copy whose file names
11//! quietly differ from the library's is a copy nobody can compare against the
12//! original afterwards.
13
14use std::collections::BTreeSet;
15
16/// Characters no FAT/exFAT/NTFS volume accepts in a name.
17///
18/// `/` is absent on purpose: it never reaches this function, being the
19/// separator that split the path into the components given to it.
20const FORBIDDEN: &[char] = &['?', '*', ':', '"', '<', '>', '|', '\\'];
21
22/// What replaces a character that cannot be written.
23///
24/// One visible character rather than removal: `Where Is My Mind_` still reads
25/// as the title, where `Where Is My Mind` claims to be a name that was never
26/// there — and two titles differing only by punctuation would silently become
27/// one.
28const REPLACEMENT: char = '_';
29
30/// Names DOS reserved for devices, which FAT still refuses whatever the
31/// extension: `NUL.flac` cannot be created.
32const RESERVED: &[&str] = &[
33 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
34 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
35];
36
37/// Longest component the destination is assumed to accept.
38///
39/// 255 is what FAT32 with long file names, exFAT, ext4, APFS and NTFS all
40/// allow. The unit is **bytes**, not characters: an accented title is longer
41/// on disk than it looks on screen, and a limit counted in characters would
42/// pass here and fail at the write.
43const MAX_COMPONENT: usize = 255;
44
45/// Splits a name into its stem and its extension, where there is one.
46///
47/// **Not every dot ends a name.** `Vol. 1: Live` has two, and neither of them
48/// introduces an extension — taking the last one would make the stem `Vol` and
49/// the "extension" ` 1_ Live`, which is how a disambiguating counter ends up
50/// inside the title as `Vol (2). 1_ Live`. A test caught exactly that. So an
51/// extension is recognised rather than assumed: a short run of letters and
52/// digits, no spaces, after the final dot.
53///
54/// The extension comes back with its dot, so `stem + extension` is always the
55/// name that went in.
56fn split_extension(name: &str) -> (&str, &str) {
57 let Some(dot) = name.rfind('.') else {
58 return (name, "");
59 };
60 // A leading dot makes a hidden file, not an extension on an empty stem.
61 if dot == 0 {
62 return (name, "");
63 }
64 let candidate = &name[dot + 1..];
65 let plausible = !candidate.is_empty()
66 && candidate.len() <= 12
67 && candidate.chars().all(|c| c.is_ascii_alphanumeric());
68 match plausible {
69 true => (&name[..dot], &name[dot..]),
70 false => (name, ""),
71 }
72}
73
74/// A component made acceptable, or left exactly as it was.
75///
76/// Returns `None` when nothing had to change, which is what lets the caller
77/// report only the names it actually touched.
78pub fn adapt(component: &str) -> Option<String> {
79 let adapted = rewrite(component);
80 (adapted != component).then_some(adapted)
81}
82
83fn rewrite(component: &str) -> String {
84 let mut out: String = component
85 .chars()
86 // Control characters are as unwritable as the punctuation, and far
87 // more likely to come from a damaged tag than from a title.
88 .map(|c| match FORBIDDEN.contains(&c) || (c as u32) < 0x20 {
89 true => REPLACEMENT,
90 false => c,
91 })
92 .collect();
93
94 // A name ending in a dot or a space is accepted by the call and then read
95 // back without it, which turns "Vol. 2 " into a file the copy can never
96 // find again. Trimmed rather than replaced: the trailing space carries no
97 // meaning anybody wants to keep.
98 let trimmed = out.trim_end_matches([' ', '.']);
99 if trimmed.len() != out.len() {
100 out = match trimmed.is_empty() {
101 true => REPLACEMENT.to_string(),
102 false => trimmed.to_string(),
103 };
104 }
105
106 // Device names stay reserved before the first dot, even with several
107 // extensions or a suffix the collision counter would not call an extension.
108 let (stem, extension) = out.split_at(out.find('.').unwrap_or(out.len()));
109 if RESERVED.iter().any(|word| stem.eq_ignore_ascii_case(word)) {
110 out = format!("{stem}{REPLACEMENT}{extension}");
111 }
112
113 if out.len() > MAX_COMPONENT {
114 // Cutting can expose punctuation that was inside the original name.
115 out = shorten(&out).trim_end_matches([' ', '.']).to_string();
116 // Removing that ending can also leave a bare reserved device name.
117 if RESERVED.iter().any(|word| out.eq_ignore_ascii_case(word)) {
118 out.push(REPLACEMENT);
119 }
120 }
121 if out.is_empty() {
122 out = REPLACEMENT.to_string();
123 }
124 out
125}
126
127/// Cuts an overlong component down to size, keeping its extension.
128///
129/// The extension is what says what the file *is*, and a player that sorts by
130/// it would lose the track entirely; the middle of a long title is what nobody
131/// misses. Cut on a character boundary, never in the middle of one, or the
132/// result is not a string at all.
133fn shorten(component: &str) -> String {
134 let (stem, extension) = split_extension(component);
135 let room = MAX_COMPONENT.saturating_sub(extension.len());
136 let mut end = room.min(stem.len());
137 while end > 0 && !stem.is_char_boundary(end) {
138 end -= 1;
139 }
140 format!("{}{extension}", &stem[..end])
141}
142
143/// Makes a name unique among those already taken, and records it.
144///
145/// Shortening and character replacement can both map two different names onto
146/// one — `Vol. 1: Live` and `Vol. 1? Live` both become `Vol. 1_ Live` — and a
147/// copy that silently merges two albums into one folder is worse than a copy
148/// that refuses. A counter is appended, before the extension, so the file
149/// stays playable.
150pub fn make_unique(name: &str, taken: &mut BTreeSet<String>) -> String {
151 unique(name, taken, false)
152}
153
154/// Reserves a component using conservative text matching for portable copies.
155/// Case, common composed/decomposed accents and punctuation are folded; this
156/// may distinguish more names than strictly required by the current volume.
157/// The set stores matching keys rather than the displayed names.
158pub(super) fn make_unique_portable(name: &str, taken: &mut BTreeSet<String>) -> String {
159 unique(name, taken, true)
160}
161
162/// Conservative component identity for portable copy namespace admission.
163/// This is not a claim about every alias supported by a particular filesystem.
164pub fn portable_key(name: &str) -> String {
165 // Remove combining marks before textual folding so an accent inside a
166 // word does not become a separator. Uppercase first also joins Unicode
167 // case variants such as sigma and final sigma. The filesystem preflight
168 // catches aliases outside this deliberately conservative vocabulary.
169 let unmarked: String = name.chars().filter(|character| !matches!(*character as u32,
170 0x0300..=0x036f | 0x1ab0..=0x1aff | 0x1dc0..=0x1dff | 0x20d0..=0x20ff | 0xfe20..=0xfe2f
171 )).collect();
172 crate::text::normalize(&unmarked.to_uppercase())
173}
174
175fn unique(name: &str, taken: &mut BTreeSet<String>, portable: bool) -> String {
176 let key = |name: &str| {
177 if portable {
178 portable_key(name)
179 } else {
180 name.to_string()
181 }
182 };
183 if taken.insert(key(name)) {
184 return name.to_string();
185 }
186 let (stem, extension) = split_extension(name);
187 for n in 2u64.. {
188 let suffix = format!(" ({n})");
189 let room = MAX_COMPONENT.saturating_sub(extension.len() + suffix.len());
190 let mut end = room.min(stem.len());
191 while end > 0 && !stem.is_char_boundary(end) {
192 end -= 1;
193 }
194 let candidate = format!("{}{suffix}{extension}", &stem[..end]);
195 if taken.insert(key(&candidate)) {
196 return candidate;
197 }
198 }
199 unreachable!("a filesystem cannot contain u64::MAX names")
200}
201
202/// Asks the destination itself what it accepts, by trying.
203///
204/// Deliberately empirical. The alternative is to read the filesystem's *name*
205/// and infer from it, which needs platform-specific code and is wrong exactly
206/// where it matters: a FUSE mount, an SMB share of a Windows folder, or a card
207/// reader all report something the table does not list, and the inference has
208/// to guess. Creating one file settles the question for the volume actually
209/// being written to.
210///
211/// A failure to probe reads as "restricted": a copy that adapts names it did
212/// not have to costs a few odd characters, one that fails to adapt names it
213/// should have costs the run.
214pub fn restricts_names(destination: &std::path::Path) -> bool {
215 // Isolate the probe so an existing file (including a symlink) is never
216 // opened, replaced or removed. Its filename retains the questioned '?'.
217 let Ok(temporary) = super::TemporaryOutput::new(&destination.join("aede-name-probe?.tmp"))
218 else {
219 return true;
220 };
221 let probe = temporary.path().with_file_name("name?.tmp");
222 std::fs::OpenOptions::new()
223 .write(true)
224 .create_new(true)
225 .open(probe)
226 .is_err()
227}
228
229#[cfg(test)]
230#[path = "names_tests.rs"]
231mod tests;