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;