Skip to main content

minimoon_sync_server/
data.rs

1#![allow(clippy::items_after_test_module)]
2
3use anyhow::Result;
4use glob::{glob, Paths};
5use serde::{Deserialize, Serialize};
6use std::ffi::OsStr;
7use std::path::{Component, Path, PathBuf};
8use std::{fs, time::UNIX_EPOCH};
9use tracing::debug;
10
11#[derive(Clone, Copy, Debug, Default)]
12pub struct FileAccessOptions {
13    pub allow_top_level_directory_symlinks: bool,
14}
15
16#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct FileInfo {
18    pub path: String,
19    pub size: u64,
20    pub last_modified: u64,
21}
22
23static IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp"];
24static AUDIO_EXTENSIONS: &[&str] = &["m4a", "mp3", "wav", "ogg", "opus", "flac"];
25static LYRIC_EXTENSIONS: &[&str] = &["lrc"];
26
27fn is_windows_metadata_file(file_name: &str) -> bool {
28    let file_name = file_name.to_ascii_lowercase();
29    file_name == "desktop.ini"
30        || file_name.starts_with("albumartsmall")
31        || file_name.starts_with("albumart_{")
32}
33
34fn path_has_hidden_component(path: &Path) -> bool {
35    path.components().any(|component| match component {
36        Component::Normal(part) => part.to_str().is_some_and(|name| name.starts_with('.')),
37        _ => false,
38    })
39}
40
41fn path_has_hidden_attribute(path: &Path) -> bool {
42    #[cfg(windows)]
43    {
44        use std::os::windows::fs::MetadataExt;
45        const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
46        return fs::metadata(path)
47            .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0)
48            .unwrap_or(false);
49    }
50
51    #[cfg(not(windows))]
52    {
53        let _ = path;
54        false
55    }
56}
57
58fn file_is_syncable(path: &Path) -> bool {
59    if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
60        if file_name.starts_with('.') {
61            return false;
62        }
63
64        if is_windows_metadata_file(file_name) {
65            return false;
66        }
67    }
68
69    syncable_content_type(path).is_some()
70}
71
72pub fn syncable_content_type(path: &Path) -> Option<&'static str> {
73    let ext = path.extension().and_then(OsStr::to_str)?;
74    let ext = ext.to_ascii_lowercase();
75
76    if IMAGE_EXTENSIONS.contains(&ext.as_str()) {
77        return Some(match ext.as_str() {
78            "jpg" | "jpeg" => "image/jpeg",
79            "png" => "image/png",
80            "webp" => "image/webp",
81            _ => unreachable!("all image extensions have content types"),
82        });
83    }
84
85    if AUDIO_EXTENSIONS.contains(&ext.as_str()) {
86        return Some(match ext.as_str() {
87            "m4a" => "audio/mp4",
88            "mp3" => "audio/mpeg",
89            "wav" => "audio/wav",
90            "ogg" => "audio/ogg",
91            "opus" => "audio/opus",
92            "flac" => "audio/flac",
93            _ => unreachable!("all audio extensions have content types"),
94        });
95    }
96
97    if LYRIC_EXTENSIONS.contains(&ext.as_str()) {
98        return Some("text/plain; charset=utf-8");
99    }
100
101    None
102}
103
104pub fn resolve_syncable_file_path(dir: &Path, relative_path: &str) -> Result<PathBuf> {
105    resolve_syncable_file_path_with_options(dir, relative_path, FileAccessOptions::default())
106}
107
108pub fn resolve_syncable_file_path_with_options(
109    dir: &Path,
110    relative_path: &str,
111    options: FileAccessOptions,
112) -> Result<PathBuf> {
113    debug!("resolving path: {:?}", relative_path);
114
115    if relative_path.is_empty() {
116        debug!("rejected: path is empty");
117        anyhow::bail!("path cannot be empty");
118    }
119
120    if path_has_hidden_component(Path::new(relative_path)) {
121        debug!("rejected: path has hidden component: {:?}", relative_path);
122        anyhow::bail!("hidden paths are not syncable");
123    }
124
125    let mut resolved_path = dir.to_path_buf();
126    for component in Path::new(relative_path).components() {
127        match component {
128            Component::Normal(part) => resolved_path.push(part),
129            _ => {
130                debug!(
131                    "rejected: non-normal path component {:?} in {:?}",
132                    component.as_os_str(),
133                    relative_path
134                );
135                anyhow::bail!("path must be a relative child of the shared directory");
136            }
137        }
138    }
139
140    if !resolved_path.is_file() {
141        debug!("rejected: not a file on disk: {:?}", resolved_path);
142        anyhow::bail!("path does not point to a file");
143    }
144
145    if path_has_hidden_attribute(&resolved_path) {
146        debug!("rejected: hidden file attribute: {:?}", resolved_path);
147        anyhow::bail!("hidden paths are not syncable");
148    }
149
150    if !file_is_syncable(&resolved_path) {
151        debug!("rejected: not a syncable file type: {:?}", resolved_path);
152        anyhow::bail!("path is not syncable");
153    }
154
155    let canonical_dir = dir.canonicalize()?;
156    let canonical_path = resolved_path.canonicalize()?;
157    if !canonical_path.starts_with(&canonical_dir)
158        && (!options.allow_top_level_directory_symlinks
159            || !via_top_level_directory_symlink(dir, relative_path))
160    {
161        debug!(
162            "rejected: canonical path {:?} escapes shared dir {:?}",
163            canonical_path, canonical_dir
164        );
165        anyhow::bail!("path must stay inside the shared directory");
166    }
167
168    if !file_is_syncable(&canonical_path) {
169        debug!(
170            "rejected: canonical path not syncable: {:?}",
171            canonical_path
172        );
173        anyhow::bail!("path is not syncable");
174    }
175
176    debug!("resolved ok: {:?}", canonical_path);
177    Ok(canonical_path)
178}
179
180fn via_top_level_directory_symlink(dir: &Path, relative_path: &str) -> bool {
181    let first_component = Path::new(relative_path).components().next();
182    matches!(
183        first_component,
184        Some(Component::Normal(c)) if {
185            let p = dir.join(c);
186            p.is_symlink() && p.is_dir()
187        }
188    )
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{
194        file_is_syncable, iter_files, iter_files_with_options, resolve_syncable_file_path,
195        resolve_syncable_file_path_with_options, syncable_content_type, FileAccessOptions,
196    };
197    use std::fs;
198    use std::path::Path;
199    use std::time::{SystemTime, UNIX_EPOCH};
200
201    fn temp_test_dir(prefix: &str) -> std::path::PathBuf {
202        let unique = SystemTime::now()
203            .duration_since(UNIX_EPOCH)
204            .unwrap()
205            .as_nanos();
206        let path = std::env::temp_dir().join(format!("minimoon-sync-{prefix}-{unique}"));
207        fs::create_dir_all(&path).unwrap();
208        path
209    }
210
211    #[test]
212    fn excludes_windows_album_art_and_desktop_ini_files() {
213        assert!(!file_is_syncable(Path::new("AlbumArtSmall.jpg")));
214        assert!(!file_is_syncable(Path::new(
215            "AlbumArt_{12345678-1234-1234-1234-1234567890AB}_Large.jpg"
216        )));
217        assert!(!file_is_syncable(Path::new("desktop.ini")));
218    }
219
220    #[test]
221    fn keeps_normal_media_files_syncable() {
222        assert!(file_is_syncable(Path::new("track01.mp3")));
223        assert!(file_is_syncable(Path::new("TRACK01.MP3")));
224        assert!(file_is_syncable(Path::new("track01.wav")));
225        assert!(file_is_syncable(Path::new("TRACK01.WAV")));
226        assert!(file_is_syncable(Path::new("cover.jpg")));
227        assert!(file_is_syncable(Path::new("cover.png")));
228        assert!(file_is_syncable(Path::new("cover.webp")));
229        assert!(file_is_syncable(Path::new("lyrics.lrc")));
230    }
231
232    #[test]
233    fn maps_wav_files_to_audio_wav_content_type() {
234        assert_eq!(
235            syncable_content_type(Path::new("track01.wav")),
236            Some("audio/wav")
237        );
238    }
239
240    #[test]
241    fn rejects_other_text_files() {
242        assert!(!file_is_syncable(Path::new("notes.txt")));
243    }
244
245    #[test]
246    fn listing_excludes_hidden_files_and_directories() {
247        let root = temp_test_dir("hidden-list");
248        fs::write(root.join(".hidden-track.mp3"), "hidden").unwrap();
249        fs::write(root.join("visible.mp3"), "visible").unwrap();
250        let hidden_dir = root.join(".hidden-album");
251        fs::create_dir_all(&hidden_dir).unwrap();
252        fs::write(hidden_dir.join("track.mp3"), "hidden").unwrap();
253
254        let files = iter_files(root.to_string_lossy().into_owned())
255            .unwrap()
256            .collect::<anyhow::Result<Vec<_>>>()
257            .unwrap();
258
259        assert_eq!(files.len(), 1);
260        assert_eq!(files[0].path, "visible.mp3");
261        let _ = fs::remove_dir_all(root);
262    }
263
264    #[test]
265    fn iterates_over_file_info() {
266        let root = temp_test_dir("stream-list");
267        fs::write(root.join("first.mp3"), "first").unwrap();
268        fs::write(root.join("second.mp3"), "second").unwrap();
269        let mut paths = Vec::new();
270
271        paths.extend(
272            iter_files(root.to_string_lossy().into_owned())
273                .unwrap()
274                .map(|file| file.unwrap().path),
275        );
276
277        paths.sort();
278        assert_eq!(paths, ["first.mp3", "second.mp3"]);
279        let _ = fs::remove_dir_all(root);
280    }
281
282    #[test]
283    fn rejects_absolute_paths() {
284        let root = temp_test_dir("absolute");
285        let file_path = root.join("track.mp3");
286        fs::write(&file_path, "test").unwrap();
287
288        let error =
289            resolve_syncable_file_path(&root, file_path.to_string_lossy().as_ref()).unwrap_err();
290
291        assert!(error
292            .to_string()
293            .contains("relative child of the shared directory"));
294        let _ = fs::remove_dir_all(root);
295    }
296
297    #[test]
298    fn rejects_current_directory_components() {
299        let root = temp_test_dir("current-dir");
300        fs::write(root.join("track.mp3"), "test").unwrap();
301
302        let error = resolve_syncable_file_path(&root, "./track.mp3").unwrap_err();
303
304        assert!(error
305            .to_string()
306            .contains("relative child of the shared directory"));
307        let _ = fs::remove_dir_all(root);
308    }
309
310    #[cfg(unix)]
311    #[test]
312    fn rejects_symlinks_that_escape_shared_directory() {
313        use std::os::unix::fs::symlink;
314
315        let root = temp_test_dir("symlink-root");
316        let outside = temp_test_dir("symlink-outside");
317        let outside_file = outside.join("secret.mp3");
318        fs::write(&outside_file, "secret").unwrap();
319        symlink(&outside_file, root.join("linked.mp3")).unwrap();
320
321        let error = resolve_syncable_file_path(&root, "linked.mp3").unwrap_err();
322
323        assert!(error.to_string().contains("inside the shared directory"));
324        let _ = fs::remove_dir_all(root);
325        let _ = fs::remove_dir_all(outside);
326    }
327
328    #[cfg(unix)]
329    #[test]
330    fn rejects_top_level_directory_symlinks_by_default() {
331        use std::os::unix::fs::symlink;
332
333        let root = temp_test_dir("symlink-dir-root");
334        let outside = temp_test_dir("symlink-dir-outside");
335        let outside_file = outside.join("track.mp3");
336        fs::write(&outside_file, "test").unwrap();
337        // Symlink an entire directory into root (like an artist folder)
338        symlink(&outside, root.join("Artist")).unwrap();
339
340        let error = resolve_syncable_file_path(&root, "Artist/track.mp3").unwrap_err();
341
342        assert!(error.to_string().contains("inside the shared directory"));
343        let _ = fs::remove_dir_all(root);
344        let _ = fs::remove_dir_all(outside);
345    }
346
347    #[cfg(unix)]
348    #[test]
349    fn allows_files_inside_top_level_directory_symlinks_when_enabled() {
350        use std::os::unix::fs::symlink;
351
352        let root = temp_test_dir("symlink-dir-root-enabled");
353        let outside = temp_test_dir("symlink-dir-outside-enabled");
354        let outside_file = outside.join("track.mp3");
355        fs::write(&outside_file, "test").unwrap();
356        symlink(&outside, root.join("Artist")).unwrap();
357
358        let resolved = resolve_syncable_file_path_with_options(
359            &root,
360            "Artist/track.mp3",
361            FileAccessOptions {
362                allow_top_level_directory_symlinks: true,
363            },
364        )
365        .unwrap();
366
367        assert_eq!(resolved, outside_file.canonicalize().unwrap());
368        let _ = fs::remove_dir_all(root);
369        let _ = fs::remove_dir_all(outside);
370    }
371
372    #[cfg(unix)]
373    #[test]
374    fn rejects_symlinks_to_non_syncable_files_inside_shared_directory() {
375        use std::os::unix::fs::symlink;
376
377        let root = temp_test_dir("symlink-nonsyncable");
378        let target = root.join("secret.txt");
379        fs::write(&target, "secret").unwrap();
380        symlink(&target, root.join("linked.mp3")).unwrap();
381
382        let error = resolve_syncable_file_path(&root, "linked.mp3").unwrap_err();
383
384        assert!(error.to_string().contains("not syncable"));
385        let _ = fs::remove_dir_all(root);
386    }
387
388    #[test]
389    fn resolves_syncable_relative_file_paths() {
390        let root = temp_test_dir("resolve");
391        let nested_dir = root.join("Albums");
392        fs::create_dir_all(&nested_dir).unwrap();
393        let file_path = nested_dir.join("track #1?.opus");
394        fs::write(&file_path, "test").unwrap();
395
396        let resolved = resolve_syncable_file_path(&root, "Albums/track #1?.opus").unwrap();
397
398        assert_eq!(resolved, file_path.canonicalize().unwrap());
399        let _ = fs::remove_dir_all(root);
400    }
401
402    #[test]
403    fn rejects_parent_directory_traversal() {
404        let root = temp_test_dir("traversal");
405
406        let error = resolve_syncable_file_path(&root, "../secret.mp3").unwrap_err();
407
408        assert!(error
409            .to_string()
410            .contains("relative child of the shared directory"));
411        let _ = fs::remove_dir_all(root);
412    }
413
414    #[test]
415    fn rejects_non_syncable_files() {
416        let root = temp_test_dir("nonsyncable");
417        let file_path = root.join("notes.txt");
418        fs::write(&file_path, "test").unwrap();
419
420        let error = resolve_syncable_file_path(&root, "notes.txt").unwrap_err();
421
422        assert!(error.to_string().contains("not syncable"));
423        let _ = fs::remove_dir_all(root);
424    }
425
426    #[test]
427    fn rejects_hidden_relative_file_paths() {
428        let root = temp_test_dir("hidden-resolve");
429        let hidden_dir = root.join(".hidden-album");
430        fs::create_dir_all(&hidden_dir).unwrap();
431        fs::write(hidden_dir.join("track.mp3"), "test").unwrap();
432
433        let error = resolve_syncable_file_path(&root, ".hidden-album/track.mp3").unwrap_err();
434
435        assert!(error.to_string().contains("hidden paths"));
436        let _ = fs::remove_dir_all(root);
437    }
438
439    #[cfg(unix)]
440    #[test]
441    fn listing_excludes_top_level_directory_symlinks_by_default() {
442        use std::os::unix::fs::symlink;
443
444        let root = temp_test_dir("symlink-list-root");
445        let outside = temp_test_dir("symlink-list-outside");
446        fs::write(outside.join("track.mp3"), "test").unwrap();
447        symlink(&outside, root.join("Artist")).unwrap();
448
449        let files = iter_files(root.to_string_lossy().into_owned())
450            .unwrap()
451            .collect::<anyhow::Result<Vec<_>>>()
452            .unwrap();
453
454        assert!(files.is_empty());
455        let _ = fs::remove_dir_all(root);
456        let _ = fs::remove_dir_all(outside);
457    }
458
459    #[cfg(unix)]
460    #[test]
461    fn listing_includes_top_level_directory_symlinks_when_enabled() {
462        use std::os::unix::fs::symlink;
463
464        let root = temp_test_dir("symlink-list-root-enabled");
465        let outside = temp_test_dir("symlink-list-outside-enabled");
466        fs::write(outside.join("track.mp3"), "test").unwrap();
467        symlink(&outside, root.join("Artist")).unwrap();
468
469        let files = iter_files_with_options(
470            root.to_string_lossy().into_owned(),
471            FileAccessOptions {
472                allow_top_level_directory_symlinks: true,
473            },
474        )
475        .unwrap()
476        .collect::<anyhow::Result<Vec<_>>>()
477        .unwrap();
478
479        assert_eq!(files.len(), 1);
480        assert_eq!(files[0].path, "Artist/track.mp3");
481        let _ = fs::remove_dir_all(root);
482        let _ = fs::remove_dir_all(outside);
483    }
484}
485
486fn file_info(dir: &Path, path: &Path) -> Result<FileInfo> {
487    let metadata = fs::metadata(path).unwrap();
488    let last_modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_secs();
489    let relative_path = path.strip_prefix(dir)?;
490    Ok(FileInfo {
491        path: relative_path.to_string_lossy().replace('\\', "/"),
492        size: metadata.len(),
493        last_modified: last_modified * 1000,
494    })
495}
496
497pub struct FileInfoIter {
498    root_path: PathBuf,
499    entries: Option<Paths>,
500    options: FileAccessOptions,
501}
502
503impl Iterator for FileInfoIter {
504    type Item = Result<FileInfo>;
505
506    fn next(&mut self) -> Option<Self::Item> {
507        let entries = self.entries.as_mut()?;
508        loop {
509            let path = match entries.next()? {
510                Ok(path) => path,
511                Err(error) => {
512                    eprintln!("{error:?}");
513                    continue;
514                }
515            };
516            let relative_path = match path.strip_prefix(&self.root_path) {
517                Ok(path) => path,
518                Err(error) => return Some(Err(error.into())),
519            };
520            let relative_path_string = relative_path.to_string_lossy();
521            if path.is_file()
522                && !path_has_hidden_component(relative_path)
523                && !path_has_hidden_attribute(path.as_path())
524                && file_is_syncable(path.as_path())
525                && resolve_syncable_file_path_with_options(
526                    &self.root_path,
527                    relative_path_string.as_ref(),
528                    self.options,
529                )
530                .is_ok()
531            {
532                return Some(file_info(&self.root_path, path.as_path()));
533            }
534        }
535    }
536}
537
538pub fn iter_files(dir: impl Into<PathBuf>) -> Result<FileInfoIter> {
539    iter_files_with_options(dir, FileAccessOptions::default())
540}
541
542pub fn iter_files_with_options(
543    dir: impl Into<PathBuf>,
544    options: FileAccessOptions,
545) -> Result<FileInfoIter> {
546    let root_path = dir.into();
547    let entries = if root_path.as_os_str().is_empty() {
548        None
549    } else {
550        let pattern = format!("{}/**/*", root_path.to_string_lossy());
551        Some(glob(&pattern)?)
552    };
553
554    Ok(FileInfoIter {
555        root_path,
556        entries,
557        options,
558    })
559}