1use crate::codec::compress::is_incompressible_extension;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::wire::{EntryKind, FileEntry};
11use std::path::{Component, Path, PathBuf};
12
13fn is_own_sidecar(name: &str) -> bool {
17 name.ends_with(crate::io::writer::PART_SUFFIX)
18 || name.ends_with(crate::resume::STATE_SUFFIX)
19 || name == crate::index::INDEX_FILE
20}
21
22#[derive(Debug, Clone)]
24pub struct Source {
25 pub path: PathBuf,
26 pub name: Option<String>,
29}
30
31impl Source {
32 pub fn new(path: impl Into<PathBuf>) -> Self {
33 Self {
34 path: path.into(),
35 name: None,
36 }
37 }
38 pub fn with_name(path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
39 Self {
40 path: path.into(),
41 name: Some(name.into()),
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct Manifest {
49 pub entries: Vec<FileEntry>,
50 pub local_paths: Vec<PathBuf>,
52 pub audio: Vec<Option<crate::codec::pcm::AudioFormat>>,
58}
59
60impl Manifest {
61 pub fn total_bytes(&self) -> u64 {
62 self.entries
63 .iter()
64 .filter(|e| e.kind == EntryKind::File)
65 .map(|e| e.size)
66 .sum()
67 }
68
69 pub fn file_count(&self) -> usize {
70 self.entries
71 .iter()
72 .filter(|e| e.kind == EntryKind::File)
73 .count()
74 }
75}
76
77pub async fn build(sources: &[Source], cfg: &Config) -> Result<Manifest> {
84 let mut entries = Vec::new();
85 let mut local_paths = Vec::new();
86 let mut next_id: u32 = 0;
87
88 for src in sources {
89 let meta = tokio::fs::symlink_metadata(&src.path).await.map_err(|e| {
90 Error::Io(std::io::Error::new(
91 e.kind(),
92 format!("{}: {e}", src.path.display()),
93 ))
94 })?;
95
96 let base_name = match &src.name {
97 Some(n) => n.clone(),
98 None => src
99 .path
100 .file_name()
101 .map(|s| s.to_string_lossy().into_owned())
102 .ok_or_else(|| Error::Config(format!("{:?} has no file name", src.path)))?,
103 };
104
105 if meta.is_file() {
106 push_file(
107 &mut entries,
108 &mut local_paths,
109 &mut next_id,
110 cfg,
111 &src.path,
112 base_name,
113 &meta,
114 )?;
115 continue;
116 }
117
118 if meta.is_symlink() {
119 push_symlink(
120 &mut entries,
121 &mut local_paths,
122 &mut next_id,
123 &src.path,
124 base_name,
125 )
126 .await?;
127 continue;
128 }
129
130 if !meta.is_dir() {
131 continue;
133 }
134
135 let mut stack = vec![(src.path.clone(), base_name.clone())];
137 push_dir(
139 &mut entries,
140 &mut local_paths,
141 &mut next_id,
142 &src.path,
143 base_name,
144 &meta,
145 )?;
146
147 while let Some((dir, rel)) = stack.pop() {
148 let mut rd = match tokio::fs::read_dir(&dir).await {
149 Ok(rd) => rd,
150 Err(e) => {
151 tracing::warn!(path = %dir.display(), error = %e, "skipping unreadable directory");
152 continue;
153 }
154 };
155 while let Some(item) = rd.next_entry().await? {
156 let name = item.file_name().to_string_lossy().into_owned();
157 if is_own_sidecar(&name) {
158 continue;
159 }
160 let child_rel = format!("{rel}/{name}");
161 let child_path = item.path();
162 let m = match tokio::fs::symlink_metadata(&child_path).await {
163 Ok(m) => m,
164 Err(e) => {
165 tracing::warn!(path = %child_path.display(), error = %e, "skipping unreadable entry");
166 continue;
167 }
168 };
169 if m.is_symlink() {
170 push_symlink(
171 &mut entries,
172 &mut local_paths,
173 &mut next_id,
174 &child_path,
175 child_rel,
176 )
177 .await?;
178 } else if m.is_dir() {
179 push_dir(
180 &mut entries,
181 &mut local_paths,
182 &mut next_id,
183 &child_path,
184 child_rel.clone(),
185 &m,
186 )?;
187 stack.push((child_path, child_rel));
188 } else if m.is_file() {
189 push_file(
190 &mut entries,
191 &mut local_paths,
192 &mut next_id,
193 cfg,
194 &child_path,
195 child_rel,
196 &m,
197 )?;
198 }
199 }
200 }
201 }
202
203 if entries.len() > cfg.max_manifest_entries {
204 return Err(Error::Config(format!(
205 "manifest has {} entries, limit is {}",
206 entries.len(),
207 cfg.max_manifest_entries
208 )));
209 }
210
211 let mut audio = Vec::with_capacity(entries.len());
213 for (entry, path) in entries.iter().zip(&local_paths) {
214 audio.push(
215 if entry.kind == EntryKind::File && looks_like_pcm(&entry.path) {
216 read_audio_format(path).await
217 } else {
218 None
219 },
220 );
221 }
222
223 Ok(Manifest {
224 entries,
225 local_paths,
226 audio,
227 })
228}
229
230fn looks_like_pcm(path: &str) -> bool {
232 let Some((_, ext)) = path.rsplit_once('.') else {
233 return false;
234 };
235 matches!(
236 ext.to_ascii_lowercase().as_str(),
237 "wav" | "wave" | "bwf" | "w64" | "rf64"
238 )
239}
240
241async fn read_audio_format(path: &Path) -> Option<crate::codec::pcm::AudioFormat> {
243 use tokio::io::AsyncReadExt;
244 let mut f = tokio::fs::File::open(path).await.ok()?;
245 let mut head = vec![0u8; 8192];
247 let n = f.read(&mut head).await.ok()?;
248 head.truncate(n);
249 crate::codec::pcm::parse_wav_header(&head)
250}
251
252fn push_file(
253 entries: &mut Vec<FileEntry>,
254 paths: &mut Vec<PathBuf>,
255 next_id: &mut u32,
256 cfg: &Config,
257 path: &Path,
258 rel: String,
259 meta: &std::fs::Metadata,
260) -> Result<()> {
261 let size = meta.len();
262 entries.push(FileEntry {
263 file_id: *next_id,
264 incompressible: is_incompressible_extension(&cfg.compression, &rel),
265 path: rel,
266 size,
267 chunk_size: cfg.chunk_size as u32,
268 mode: unix_mode(meta),
269 mtime: mtime_secs(meta),
270 kind: EntryKind::File,
271 hash: None,
272 });
273 paths.push(path.to_path_buf());
274 *next_id += 1;
275 Ok(())
276}
277
278fn push_dir(
279 entries: &mut Vec<FileEntry>,
280 paths: &mut Vec<PathBuf>,
281 next_id: &mut u32,
282 path: &Path,
283 rel: String,
284 meta: &std::fs::Metadata,
285) -> Result<()> {
286 entries.push(FileEntry {
287 file_id: *next_id,
288 path: rel,
289 size: 0,
290 chunk_size: 0,
291 mode: unix_mode(meta),
292 mtime: mtime_secs(meta),
293 kind: EntryKind::Directory,
294 hash: None,
295 incompressible: false,
296 });
297 paths.push(path.to_path_buf());
298 *next_id += 1;
299 Ok(())
300}
301
302async fn push_symlink(
303 entries: &mut Vec<FileEntry>,
304 paths: &mut Vec<PathBuf>,
305 next_id: &mut u32,
306 path: &Path,
307 rel: String,
308) -> Result<()> {
309 let target = tokio::fs::read_link(path).await?;
310 let target = target.to_string_lossy().into_owned();
311 entries.push(FileEntry {
314 file_id: *next_id,
315 path: format!("{rel}\0{target}"),
316 size: 0,
317 chunk_size: 0,
318 mode: 0o777,
319 mtime: 0,
320 kind: EntryKind::Symlink,
321 hash: None,
322 incompressible: false,
323 });
324 paths.push(path.to_path_buf());
325 *next_id += 1;
326 Ok(())
327}
328
329#[cfg(unix)]
330fn unix_mode(meta: &std::fs::Metadata) -> u32 {
331 use std::os::unix::fs::PermissionsExt;
332 meta.permissions().mode() & 0o7777
333}
334
335#[cfg(not(unix))]
336fn unix_mode(meta: &std::fs::Metadata) -> u32 {
337 if meta.permissions().readonly() {
338 0o444
339 } else {
340 0o644
341 }
342}
343
344fn mtime_secs(meta: &std::fs::Metadata) -> i64 {
345 meta.modified()
346 .ok()
347 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
348 .map(|d| d.as_secs() as i64)
349 .unwrap_or(0)
350}
351
352pub fn safe_join(root: &Path, rel: &str) -> Result<PathBuf> {
365 if rel.is_empty() {
366 return Err(Error::UnsafePath(PathBuf::from(rel)));
367 }
368 if rel.contains('\0') {
370 return Err(Error::UnsafePath(PathBuf::from(rel)));
371 }
372 let normalised = rel.replace('\\', "/");
374 let candidate = Path::new(&normalised);
375
376 let mut out = root.to_path_buf();
377 let mut depth = 0usize;
378 for comp in candidate.components() {
379 match comp {
380 Component::Normal(part) => {
381 let s = part.to_string_lossy();
382 if s.chars().all(|c| c == '.') {
385 return Err(Error::UnsafePath(PathBuf::from(rel)));
386 }
387 out.push(part);
388 depth += 1;
389 }
390 Component::CurDir => {}
391 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
392 return Err(Error::UnsafePath(PathBuf::from(rel)));
393 }
394 }
395 }
396 if depth == 0 {
397 return Err(Error::UnsafePath(PathBuf::from(rel)));
398 }
399 Ok(out)
400}
401
402pub fn split_symlink(encoded: &str) -> Result<(&str, &str)> {
404 encoded
405 .split_once('\0')
406 .ok_or_else(|| Error::protocol("symlink entry is missing its target"))
407}
408
409pub fn validate(entries: &[FileEntry], cfg: &Config) -> Result<()> {
411 if entries.len() > cfg.max_manifest_entries {
412 return Err(Error::protocol(
413 "manifest exceeds the configured entry limit",
414 ));
415 }
416 let mut seen = std::collections::HashSet::with_capacity(entries.len());
417 for e in entries {
418 if !seen.insert(e.file_id) {
419 return Err(Error::protocol(format!(
420 "manifest reuses file_id {}",
421 e.file_id
422 )));
423 }
424 if e.kind == EntryKind::File {
425 if e.chunk_size == 0 && e.size > 0 {
426 return Err(Error::protocol("file entry has chunk_size 0"));
427 }
428 if e.chunk_size as usize > cfg.max_frame_bytes {
431 return Err(Error::protocol("file entry chunk_size exceeds frame limit"));
432 }
433 }
434 }
435 Ok(())
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn safe_join_accepts_ordinary_paths() {
444 let root = Path::new("/dest");
445 assert_eq!(
446 safe_join(root, "a/b/c.txt").unwrap(),
447 PathBuf::from("/dest/a/b/c.txt")
448 );
449 assert_eq!(
450 safe_join(root, "./a/./b.txt").unwrap(),
451 PathBuf::from("/dest/a/b.txt")
452 );
453 assert_eq!(
454 safe_join(root, "album name/01 - track.flac").unwrap(),
455 PathBuf::from("/dest/album name/01 - track.flac")
456 );
457 }
458
459 #[test]
460 fn safe_join_rejects_traversal() {
461 let root = Path::new("/dest");
462 for bad in [
463 "../etc/passwd",
464 "a/../../etc/passwd",
465 "/etc/passwd",
466 "..",
467 "./..",
468 "a/..",
469 "....//etc",
470 "..\\..\\windows\\system32",
471 "a\\..\\..\\b",
472 "",
473 "with\0nul",
474 ] {
475 assert!(
476 safe_join(root, bad).is_err(),
477 "should have rejected {bad:?}"
478 );
479 }
480 }
481
482 #[test]
483 fn safe_join_rejects_windows_prefixes() {
484 let root = Path::new("/dest");
485 let r = safe_join(root, "C:/windows/system32");
488 #[cfg(windows)]
489 assert!(r.is_err());
490 #[cfg(not(windows))]
491 {
492 let p = r.unwrap();
495 assert!(p.starts_with("/dest"));
496 }
497 }
498
499 #[test]
500 fn safe_join_output_always_stays_under_root() {
501 let root = Path::new("/dest");
502 for candidate in ["a", "a/b", "a/b/c", "x.txt", "deeply/nested/path/file.bin"] {
503 let p = safe_join(root, candidate).unwrap();
504 assert!(p.starts_with(root), "{candidate} escaped to {p:?}");
505 }
506 }
507
508 #[test]
509 fn validate_rejects_duplicate_ids() {
510 let cfg = Config::default();
511 let mk = |id: u32| FileEntry {
512 file_id: id,
513 path: "a".into(),
514 size: 10,
515 chunk_size: 1024,
516 mode: 0o644,
517 mtime: 0,
518 kind: EntryKind::File,
519 hash: None,
520 incompressible: false,
521 };
522 assert!(validate(&[mk(1), mk(2)], &cfg).is_ok());
523 assert!(validate(&[mk(1), mk(1)], &cfg).is_err());
524 }
525
526 #[test]
527 fn validate_rejects_absurd_chunk_size() {
528 let cfg = Config::default();
529 let e = FileEntry {
530 file_id: 1,
531 path: "a".into(),
532 size: 10,
533 chunk_size: u32::MAX,
534 mode: 0,
535 mtime: 0,
536 kind: EntryKind::File,
537 hash: None,
538 incompressible: false,
539 };
540 assert!(validate(&[e], &cfg).is_err());
541 }
542
543 #[tokio::test]
544 async fn build_walks_a_tree_without_following_symlinks() {
545 let tmp = tempfile::tempdir().unwrap();
546 let root = tmp.path().join("src");
547 tokio::fs::create_dir_all(root.join("sub/deep"))
548 .await
549 .unwrap();
550 tokio::fs::write(root.join("a.txt"), b"hello")
551 .await
552 .unwrap();
553 tokio::fs::write(root.join("sub/b.flac"), b"x".repeat(100))
554 .await
555 .unwrap();
556 tokio::fs::write(root.join("sub/deep/c.bin"), b"y".repeat(50))
557 .await
558 .unwrap();
559 #[cfg(unix)]
560 std::os::unix::fs::symlink("/etc", root.join("escape")).unwrap();
561
562 let cfg = Config::default();
563 let m = build(&[Source::new(&root)], &cfg).await.unwrap();
564
565 assert_eq!(m.file_count(), 3);
566 assert_eq!(m.total_bytes(), 5 + 100 + 50);
567 assert_eq!(m.entries.len(), m.local_paths.len());
568
569 for e in &m.entries {
571 assert!(e.path.starts_with("src"), "unexpected path {}", e.path);
572 assert!(!e.path.contains(".."));
573 }
574 let flac = m
577 .entries
578 .iter()
579 .find(|e| e.path.ends_with("b.flac"))
580 .unwrap();
581 assert!(flac.incompressible);
582 let txt = m
583 .entries
584 .iter()
585 .find(|e| e.path.ends_with("a.txt"))
586 .unwrap();
587 assert!(!txt.incompressible);
588
589 #[cfg(unix)]
590 {
591 let link = m
593 .entries
594 .iter()
595 .find(|e| e.kind == EntryKind::Symlink)
596 .expect("symlink recorded");
597 let (p, target) = split_symlink(&link.path).unwrap();
598 assert!(p.ends_with("escape"));
599 assert_eq!(target, "/etc");
600 }
601 }
602
603 #[tokio::test]
604 async fn build_handles_empty_files_and_dirs() {
605 let tmp = tempfile::tempdir().unwrap();
606 let root = tmp.path().join("s");
607 tokio::fs::create_dir_all(root.join("emptydir"))
608 .await
609 .unwrap();
610 tokio::fs::write(root.join("empty.bin"), b"").await.unwrap();
611 let m = build(&[Source::new(&root)], &Config::default())
612 .await
613 .unwrap();
614 assert_eq!(m.total_bytes(), 0);
615 let f = m
616 .entries
617 .iter()
618 .find(|e| e.path.ends_with("empty.bin"))
619 .unwrap();
620 assert_eq!(f.chunk_count(), 0);
621 assert!(m
622 .entries
623 .iter()
624 .any(|e| e.kind == EntryKind::Directory && e.path.ends_with("emptydir")));
625 }
626}