Skip to main content

runsync_transfer/
manifest.rs

1//! Building the file list on the sender, and validating it on the receiver.
2//!
3//! The receiver treats every path in a manifest as hostile input. A transfer
4//! is a remote peer writing to your filesystem; path handling is the part that
5//! turns that from a feature into a vulnerability.
6
7use 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
13/// Files this engine writes for its own bookkeeping, which must never be
14/// transferred: a destination that later becomes a source would otherwise ship
15/// its own partial files and caches to the next peer.
16fn 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/// What to send: a file, or a directory sent recursively.
23#[derive(Debug, Clone)]
24pub struct Source {
25    pub path: PathBuf,
26    /// Path prefix the receiver should use, relative to its destination root.
27    /// Defaults to the source's file name.
28    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/// A manifest paired with the local paths each entry reads from.
47#[derive(Debug, Clone)]
48pub struct Manifest {
49    pub entries: Vec<FileEntry>,
50    /// Parallel to `entries`: where to read each one locally.
51    pub local_paths: Vec<PathBuf>,
52    /// Parallel to `entries`: the PCM layout, for files that have one.
53    ///
54    /// Sender-side only, and deliberately not on the wire: an encoded audio
55    /// chunk carries everything its decoder needs, so the receiver never has to
56    /// be told and a peer can never lie about it.
57    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
77/// Walk `sources` and build the manifest.
78///
79/// Directories are walked iteratively rather than recursively, so a pathological
80/// tree depth cannot blow the stack. Symlinks are recorded as symlinks and never
81/// followed — following them would let a symlink to `/` turn one directory into
82/// the whole filesystem.
83pub 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            // Sockets, FIFOs, devices: nothing sensible to transfer.
132            continue;
133        }
134
135        // Iterative walk with an explicit stack.
136        let mut stack = vec![(src.path.clone(), base_name.clone())];
137        // Record the root directory itself so an empty tree still materialises.
138        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    // Sniff container headers once per file, not once per chunk.
212    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
230/// Extensions that hold uncompressed PCM, and so are worth sniffing.
231fn 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
241/// Read just enough of a file's head to recognise its sample layout.
242async 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    // Enough for a fmt chunk plus a few metadata chunks ahead of `data`.
246    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    // The target rides in the path field after a NUL, keeping the wire format
312    // to a single string field.
313    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
352// ---------------------------------------------------------------------------
353// Receiver-side validation
354// ---------------------------------------------------------------------------
355
356/// Resolve a manifest path against `root`, refusing anything that would land
357/// outside it.
358///
359/// Rejected: absolute paths, any `..` component, Windows prefixes such as
360/// `C:` or `\\?\`, and root components. Backslashes are treated as separators
361/// so a `..\..` payload cannot slip past on a platform that honours them.
362/// The check is on components, not on the resolved string, so it holds without
363/// touching the filesystem and cannot be defeated by a race.
364pub fn safe_join(root: &Path, rel: &str) -> Result<PathBuf> {
365    if rel.is_empty() {
366        return Err(Error::UnsafePath(PathBuf::from(rel)));
367    }
368    // NUL is a separator in our symlink encoding and is never valid in a path.
369    if rel.contains('\0') {
370        return Err(Error::UnsafePath(PathBuf::from(rel)));
371    }
372    // Normalise the separator before component analysis.
373    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                // A component that is only dots is never a legitimate name and
383                // is a common normalisation-bypass shape.
384                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
402/// Split the symlink encoding back into `(path, target)`.
403pub 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
409/// Check a received manifest for internal consistency before acting on it.
410pub 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            // Reject a chunk_size we would refuse to buffer anyway, before
429            // allocating anything sized from it.
430            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        // On unix these parse as Normal components, so the dedicated checks
486        // below are what stop them; on Windows they parse as Prefix/RootDir.
487        let r = safe_join(root, "C:/windows/system32");
488        #[cfg(windows)]
489        assert!(r.is_err());
490        #[cfg(not(windows))]
491        {
492            // Not a traversal on unix: "C:" is just a directory name, and the
493            // result still lands under the root, which is the actual invariant.
494            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        // Every path is relative and rooted at the source's name.
570        for e in &m.entries {
571            assert!(e.path.starts_with("src"), "unexpected path {}", e.path);
572            assert!(!e.path.contains(".."));
573        }
574        // The .flac was flagged so the receiver can report it, and the sender
575        // will skip its compression probe.
576        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            // The symlink is an entry, not an expansion of /etc.
592            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}