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(&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}