use crate::codec::compress::is_incompressible_extension;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::wire::{EntryKind, FileEntry};
use std::path::{Component, Path, PathBuf};
fn is_own_sidecar(name: &str) -> bool {
name.ends_with(crate::io::writer::PART_SUFFIX)
|| name.ends_with(crate::resume::STATE_SUFFIX)
|| name == crate::index::INDEX_FILE
}
#[derive(Debug, Clone)]
pub struct Source {
pub path: PathBuf,
pub name: Option<String>,
}
impl Source {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
name: None,
}
}
pub fn with_name(path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
path: path.into(),
name: Some(name.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct Manifest {
pub entries: Vec<FileEntry>,
pub local_paths: Vec<PathBuf>,
pub audio: Vec<Option<crate::codec::pcm::AudioFormat>>,
}
impl Manifest {
pub fn total_bytes(&self) -> u64 {
self.entries
.iter()
.filter(|e| e.kind == EntryKind::File)
.map(|e| e.size)
.sum()
}
pub fn file_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.kind == EntryKind::File)
.count()
}
}
pub async fn build(sources: &[Source], cfg: &Config) -> Result<Manifest> {
let mut entries = Vec::new();
let mut local_paths = Vec::new();
let mut next_id: u32 = 0;
for src in sources {
let meta = tokio::fs::symlink_metadata(&src.path).await.map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", src.path.display()),
))
})?;
let base_name = match &src.name {
Some(n) => n.clone(),
None => src
.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.ok_or_else(|| Error::Config(format!("{:?} has no file name", src.path)))?,
};
if meta.is_file() {
push_file(
&mut entries,
&mut local_paths,
&mut next_id,
cfg,
&src.path,
base_name,
&meta,
)?;
continue;
}
if meta.is_symlink() {
push_symlink(
&mut entries,
&mut local_paths,
&mut next_id,
&src.path,
base_name,
)
.await?;
continue;
}
if !meta.is_dir() {
continue;
}
let mut stack = vec![(src.path.clone(), base_name.clone())];
push_dir(
&mut entries,
&mut local_paths,
&mut next_id,
&src.path,
base_name,
&meta,
)?;
while let Some((dir, rel)) = stack.pop() {
let mut rd = match tokio::fs::read_dir(&dir).await {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(path = %dir.display(), error = %e, "skipping unreadable directory");
continue;
}
};
while let Some(item) = rd.next_entry().await? {
let name = item.file_name().to_string_lossy().into_owned();
if is_own_sidecar(&name) {
continue;
}
let child_rel = format!("{rel}/{name}");
let child_path = item.path();
let m = match tokio::fs::symlink_metadata(&child_path).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(path = %child_path.display(), error = %e, "skipping unreadable entry");
continue;
}
};
if m.is_symlink() {
push_symlink(
&mut entries,
&mut local_paths,
&mut next_id,
&child_path,
child_rel,
)
.await?;
} else if m.is_dir() {
push_dir(
&mut entries,
&mut local_paths,
&mut next_id,
&child_path,
child_rel.clone(),
&m,
)?;
stack.push((child_path, child_rel));
} else if m.is_file() {
push_file(
&mut entries,
&mut local_paths,
&mut next_id,
cfg,
&child_path,
child_rel,
&m,
)?;
}
}
}
}
if entries.len() > cfg.max_manifest_entries {
return Err(Error::Config(format!(
"manifest has {} entries, limit is {}",
entries.len(),
cfg.max_manifest_entries
)));
}
let mut audio = Vec::with_capacity(entries.len());
for (entry, path) in entries.iter().zip(&local_paths) {
audio.push(
if entry.kind == EntryKind::File && looks_like_pcm(&entry.path) {
read_audio_format(path).await
} else {
None
},
);
}
Ok(Manifest {
entries,
local_paths,
audio,
})
}
fn looks_like_pcm(path: &str) -> bool {
let Some((_, ext)) = path.rsplit_once('.') else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"wav" | "wave" | "bwf" | "w64" | "rf64"
)
}
async fn read_audio_format(path: &Path) -> Option<crate::codec::pcm::AudioFormat> {
use tokio::io::AsyncReadExt;
let mut f = tokio::fs::File::open(path).await.ok()?;
let mut head = vec![0u8; 8192];
let n = f.read(&mut head).await.ok()?;
head.truncate(n);
crate::codec::pcm::parse_wav_header(&head)
}
fn push_file(
entries: &mut Vec<FileEntry>,
paths: &mut Vec<PathBuf>,
next_id: &mut u32,
cfg: &Config,
path: &Path,
rel: String,
meta: &std::fs::Metadata,
) -> Result<()> {
let size = meta.len();
entries.push(FileEntry {
file_id: *next_id,
incompressible: is_incompressible_extension(&cfg.compression, &rel),
path: rel,
size,
chunk_size: cfg.chunk_size as u32,
mode: unix_mode(meta),
mtime: mtime_secs(meta),
kind: EntryKind::File,
hash: None,
});
paths.push(path.to_path_buf());
*next_id += 1;
Ok(())
}
fn push_dir(
entries: &mut Vec<FileEntry>,
paths: &mut Vec<PathBuf>,
next_id: &mut u32,
path: &Path,
rel: String,
meta: &std::fs::Metadata,
) -> Result<()> {
entries.push(FileEntry {
file_id: *next_id,
path: rel,
size: 0,
chunk_size: 0,
mode: unix_mode(meta),
mtime: mtime_secs(meta),
kind: EntryKind::Directory,
hash: None,
incompressible: false,
});
paths.push(path.to_path_buf());
*next_id += 1;
Ok(())
}
async fn push_symlink(
entries: &mut Vec<FileEntry>,
paths: &mut Vec<PathBuf>,
next_id: &mut u32,
path: &Path,
rel: String,
) -> Result<()> {
let target = tokio::fs::read_link(path).await?;
let target = target.to_string_lossy().into_owned();
entries.push(FileEntry {
file_id: *next_id,
path: format!("{rel}\0{target}"),
size: 0,
chunk_size: 0,
mode: 0o777,
mtime: 0,
kind: EntryKind::Symlink,
hash: None,
incompressible: false,
});
paths.push(path.to_path_buf());
*next_id += 1;
Ok(())
}
#[cfg(unix)]
fn unix_mode(meta: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
fn unix_mode(meta: &std::fs::Metadata) -> u32 {
if meta.permissions().readonly() {
0o444
} else {
0o644
}
}
fn mtime_secs(meta: &std::fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn safe_join(root: &Path, rel: &str) -> Result<PathBuf> {
if rel.is_empty() {
return Err(Error::UnsafePath(PathBuf::from(rel)));
}
if rel.contains('\0') {
return Err(Error::UnsafePath(PathBuf::from(rel)));
}
let normalised = rel.replace('\\', "/");
let candidate = Path::new(&normalised);
let mut out = root.to_path_buf();
let mut depth = 0usize;
for comp in candidate.components() {
match comp {
Component::Normal(part) => {
let s = part.to_string_lossy();
if s.chars().all(|c| c == '.') {
return Err(Error::UnsafePath(PathBuf::from(rel)));
}
out.push(part);
depth += 1;
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(Error::UnsafePath(PathBuf::from(rel)));
}
}
}
if depth == 0 {
return Err(Error::UnsafePath(PathBuf::from(rel)));
}
Ok(out)
}
pub fn split_symlink(encoded: &str) -> Result<(&str, &str)> {
encoded
.split_once('\0')
.ok_or_else(|| Error::protocol("symlink entry is missing its target"))
}
pub fn validate(entries: &[FileEntry], cfg: &Config) -> Result<()> {
if entries.len() > cfg.max_manifest_entries {
return Err(Error::protocol(
"manifest exceeds the configured entry limit",
));
}
let mut seen = std::collections::HashSet::with_capacity(entries.len());
for e in entries {
if !seen.insert(e.file_id) {
return Err(Error::protocol(format!(
"manifest reuses file_id {}",
e.file_id
)));
}
if e.kind == EntryKind::File {
if e.chunk_size == 0 && e.size > 0 {
return Err(Error::protocol("file entry has chunk_size 0"));
}
if e.chunk_size as usize > cfg.max_frame_bytes {
return Err(Error::protocol("file entry chunk_size exceeds frame limit"));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_accepts_ordinary_paths() {
let root = Path::new("/dest");
assert_eq!(
safe_join(root, "a/b/c.txt").unwrap(),
PathBuf::from("/dest/a/b/c.txt")
);
assert_eq!(
safe_join(root, "./a/./b.txt").unwrap(),
PathBuf::from("/dest/a/b.txt")
);
assert_eq!(
safe_join(root, "album name/01 - track.flac").unwrap(),
PathBuf::from("/dest/album name/01 - track.flac")
);
}
#[test]
fn safe_join_rejects_traversal() {
let root = Path::new("/dest");
for bad in [
"../etc/passwd",
"a/../../etc/passwd",
"/etc/passwd",
"..",
"./..",
"a/..",
"....//etc",
"..\\..\\windows\\system32",
"a\\..\\..\\b",
"",
"with\0nul",
] {
assert!(
safe_join(root, bad).is_err(),
"should have rejected {bad:?}"
);
}
}
#[test]
fn safe_join_rejects_windows_prefixes() {
let root = Path::new("/dest");
let r = safe_join(root, "C:/windows/system32");
#[cfg(windows)]
assert!(r.is_err());
#[cfg(not(windows))]
{
let p = r.unwrap();
assert!(p.starts_with("/dest"));
}
}
#[test]
fn safe_join_output_always_stays_under_root() {
let root = Path::new("/dest");
for candidate in ["a", "a/b", "a/b/c", "x.txt", "deeply/nested/path/file.bin"] {
let p = safe_join(root, candidate).unwrap();
assert!(p.starts_with(root), "{candidate} escaped to {p:?}");
}
}
#[test]
fn validate_rejects_duplicate_ids() {
let cfg = Config::default();
let mk = |id: u32| FileEntry {
file_id: id,
path: "a".into(),
size: 10,
chunk_size: 1024,
mode: 0o644,
mtime: 0,
kind: EntryKind::File,
hash: None,
incompressible: false,
};
assert!(validate(&[mk(1), mk(2)], &cfg).is_ok());
assert!(validate(&[mk(1), mk(1)], &cfg).is_err());
}
#[test]
fn validate_rejects_absurd_chunk_size() {
let cfg = Config::default();
let e = FileEntry {
file_id: 1,
path: "a".into(),
size: 10,
chunk_size: u32::MAX,
mode: 0,
mtime: 0,
kind: EntryKind::File,
hash: None,
incompressible: false,
};
assert!(validate(&[e], &cfg).is_err());
}
#[tokio::test]
async fn build_walks_a_tree_without_following_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("src");
tokio::fs::create_dir_all(root.join("sub/deep"))
.await
.unwrap();
tokio::fs::write(root.join("a.txt"), b"hello")
.await
.unwrap();
tokio::fs::write(root.join("sub/b.flac"), b"x".repeat(100))
.await
.unwrap();
tokio::fs::write(root.join("sub/deep/c.bin"), b"y".repeat(50))
.await
.unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("/etc", root.join("escape")).unwrap();
let cfg = Config::default();
let m = build(&[Source::new(&root)], &cfg).await.unwrap();
assert_eq!(m.file_count(), 3);
assert_eq!(m.total_bytes(), 5 + 100 + 50);
assert_eq!(m.entries.len(), m.local_paths.len());
for e in &m.entries {
assert!(e.path.starts_with("src"), "unexpected path {}", e.path);
assert!(!e.path.contains(".."));
}
let flac = m
.entries
.iter()
.find(|e| e.path.ends_with("b.flac"))
.unwrap();
assert!(flac.incompressible);
let txt = m
.entries
.iter()
.find(|e| e.path.ends_with("a.txt"))
.unwrap();
assert!(!txt.incompressible);
#[cfg(unix)]
{
let link = m
.entries
.iter()
.find(|e| e.kind == EntryKind::Symlink)
.expect("symlink recorded");
let (p, target) = split_symlink(&link.path).unwrap();
assert!(p.ends_with("escape"));
assert_eq!(target, "/etc");
}
}
#[tokio::test]
async fn build_handles_empty_files_and_dirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("s");
tokio::fs::create_dir_all(root.join("emptydir"))
.await
.unwrap();
tokio::fs::write(root.join("empty.bin"), b"").await.unwrap();
let m = build(&[Source::new(&root)], &Config::default())
.await
.unwrap();
assert_eq!(m.total_bytes(), 0);
let f = m
.entries
.iter()
.find(|e| e.path.ends_with("empty.bin"))
.unwrap();
assert_eq!(f.chunk_count(), 0);
assert!(m
.entries
.iter()
.any(|e| e.kind == EntryKind::Directory && e.path.ends_with("emptydir")));
}
}