diff --git a/src/common.rs b/src/common.rs index 31e79d6..e69076b 100644 --- a/src/common.rs +++ b/src/common.rs @@ -12,7 +12,7 @@ pub(crate) use std::{ io::{self, Read, Write}, num::ParseFloatError, ops::{Div, DivAssign, Mul, MulAssign}, - path::{Path, PathBuf}, + path::{self, Path, PathBuf}, process::{self, Command, ExitStatus}, str::{self, FromStr}, time::{SystemTime, SystemTimeError}, @@ -46,10 +46,10 @@ pub(crate) use crate::{ // structs and enums pub(crate) use crate::{ - bytes::Bytes, env::Env, error::Error, file_info::FileInfo, files::Files, hasher::Hasher, - info::Info, lint::Lint, linter::Linter, metainfo::Metainfo, mode::Mode, opt::Opt, + bytes::Bytes, env::Env, error::Error, file_info::FileInfo, file_path::FilePath, files::Files, + hasher::Hasher, info::Info, lint::Lint, linter::Linter, metainfo::Metainfo, mode::Mode, opt::Opt, piece_length_picker::PieceLengthPicker, platform::Platform, style::Style, table::Table, - target::Target, torrent_summary::TorrentSummary, use_color::UseColor, + target::Target, torrent_summary::TorrentSummary, use_color::UseColor, walker::Walker, }; // test stdlib types diff --git a/src/error.rs b/src/error.rs index 151b037..b2963cf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -37,6 +37,35 @@ pub(crate) enum Error { Filesystem { source: io::Error, path: PathBuf }, #[snafu(display("Failed to find opener utility, please install one of {}", tried.join(",")))] OpenerMissing { tried: &'static [&'static str] }, + #[snafu(display( + "Path `{}` contains non-normal component: {}", + path.display(), + component.display(), + ))] + PathComponent { component: PathBuf, path: PathBuf }, + #[snafu(display( + "Path `{}` contains non-unicode component: {}", + path.display(), + component.display(), + ))] + PathDecode { path: PathBuf, component: PathBuf }, + #[snafu(display( + "Path `{}` empty after stripping prefix `{}`", + path.display(), + prefix.display(), + ))] + PathStripEmpty { path: PathBuf, prefix: PathBuf }, + #[snafu(display( + "Failed to strip prefix `{}` from path `{}`: {}", + prefix.display(), + path.display(), + source + ))] + PathStripPrefix { + path: PathBuf, + prefix: PathBuf, + source: path::StripPrefixError, + }, #[snafu(display( "Piece length `{}` too large. The maximum supported piece length is {}.", bytes, diff --git a/src/file_info.rs b/src/file_info.rs index efd6a41..bc9f4c9 100644 --- a/src/file_info.rs +++ b/src/file_info.rs @@ -1,8 +1,8 @@ use crate::common::*; #[derive(Deserialize, Serialize, Debug, PartialEq)] -pub struct FileInfo { - pub length: u64, - pub md5sum: Option, - pub path: Vec, +pub(crate) struct FileInfo { + pub(crate) length: u64, + pub(crate) md5sum: Option, + pub(crate) path: FilePath, } diff --git a/src/file_path.rs b/src/file_path.rs new file mode 100644 index 0000000..e87c1e4 --- /dev/null +++ b/src/file_path.rs @@ -0,0 +1,58 @@ +use crate::common::*; + +#[serde(transparent)] +#[derive(Deserialize, Serialize, Debug, PartialEq)] +pub(crate) struct FilePath { + components: Vec, +} + +impl FilePath { + pub(crate) fn from_prefix_and_path(prefix: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(prefix) + .context(error::PathStripPrefix { prefix, path })?; + + let mut components = Vec::new(); + + for component in relative.components() { + match component { + path::Component::Normal(os) => { + if let Some(unicode) = os.to_str() { + components.push(unicode.to_owned()); + } else { + return Err(Error::PathDecode { + path: relative.to_owned(), + component: PathBuf::from(component.as_os_str()), + }); + } + } + _ => { + return Err(Error::PathComponent { + path: relative.to_owned(), + component: PathBuf::from(component.as_os_str()), + }) + } + } + } + + if components.is_empty() { + return Err(Error::PathStripEmpty { + prefix: prefix.to_owned(), + path: path.to_owned(), + }); + } + + Ok(FilePath { components }) + } + + #[cfg(test)] + pub(crate) fn from_components(components: &[&str]) -> FilePath { + let components: Vec = components + .iter() + .cloned() + .map(|component| component.to_owned()) + .collect(); + assert!(!components.is_empty()); + FilePath { components } + } +} diff --git a/src/files.rs b/src/files.rs index 67a5e88..e6704d3 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,26 +1,17 @@ use crate::common::*; pub(crate) struct Files { + root: PathBuf, total_size: Bytes, } impl Files { - pub(crate) fn from_root(root: &Path) -> Result { - let mut total_size = 0; + pub(crate) fn new(root: PathBuf, total_size: Bytes) -> Files { + Files { root, total_size } + } - for result in WalkDir::new(root).sort_by(|a, b| a.file_name().cmp(b.file_name())) { - let entry = result?; - - let metadata = entry.metadata()?; - - if metadata.is_file() { - total_size += metadata.len(); - } - } - - Ok(Files { - total_size: Bytes::from(total_size), - }) + pub(crate) fn root(&self) -> &Path { + &self.root } pub(crate) fn total_size(&self) -> Bytes { diff --git a/src/hasher.rs b/src/hasher.rs index 485afa3..14708ff 100644 --- a/src/hasher.rs +++ b/src/hasher.rs @@ -12,11 +12,11 @@ pub(crate) struct Hasher { impl Hasher { pub(crate) fn hash( - root: &Path, + files: &Files, md5sum: bool, piece_length: u32, ) -> Result<(Mode, Vec), Error> { - Self::new(md5sum, piece_length).hash_root(root) + Self::new(md5sum, piece_length).hash_root(files.root()) } fn new(md5sum: bool, piece_length: u32) -> Self { @@ -64,17 +64,28 @@ impl Hasher { } fn hash_dir(&mut self, dir: &Path) -> Result, Error> { + let mut files = Vec::new(); for result in WalkDir::new(dir).sort_by(|a, b| a.file_name().cmp(b.file_name())) { let entry = result?; let path = entry.path(); - if entry.metadata()?.is_file() { - let (_md5sum, _length) = self.hash_file(path)?; + if !entry.metadata()?.is_file() { + continue; } + + let (md5sum, length) = self.hash_file(path)?; + + let file_path = FilePath::from_prefix_and_path(dir, path)?; + + files.push(FileInfo { + md5sum: md5sum.map(|md5sum| format!("{:x}", md5sum)), + path: file_path, + length, + }); } - Ok(Vec::new()) + Ok(files) } fn hash_file(&mut self, file: &Path) -> Result<(Option, u64), Error> { diff --git a/src/main.rs b/src/main.rs index dbe4438..af855b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,7 @@ mod consts; mod env; mod error; mod file_info; +mod file_path; mod files; mod hasher; mod info; @@ -80,6 +81,7 @@ mod table; mod target; mod torrent_summary; mod use_color; +mod walker; fn main() { if let Err(code) = Env::main().status() { diff --git a/src/mode.rs b/src/mode.rs index cac84c4..d345a1e 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -2,7 +2,7 @@ use crate::common::*; #[derive(Deserialize, Serialize, Debug, PartialEq)] #[serde(untagged)] -pub enum Mode { +pub(crate) enum Mode { Single { length: u64, md5sum: Option }, Multiple { files: Vec }, } diff --git a/src/opt/torrent/create.rs b/src/opt/torrent/create.rs index d6a0dfc..c6eb40e 100644 --- a/src/opt/torrent/create.rs +++ b/src/opt/torrent/create.rs @@ -123,7 +123,7 @@ impl Create { pub(crate) fn run(self, env: &mut Env) -> Result<(), Error> { let input = env.resolve(&self.input); - let files = Files::from_root(&input)?; + let files = Walker::new(&input).files()?; let piece_length = self .piece_length @@ -209,7 +209,7 @@ impl Create { Some(String::from(consts::CREATED_BY_DEFAULT)) }; - let (mode, pieces) = Hasher::hash(&input, self.md5sum, piece_length)?; + let (mode, pieces) = Hasher::hash(&files, self.md5sum, piece_length)?; let info = Info { source: self.source, @@ -710,7 +710,35 @@ mod tests { } #[test] - fn multiple_one_file() { + fn multiple_one_file_md5() { + let mut env = environment(&["--input", "foo", "--announce", "http://bar", "--md5sum"]); + let dir = env.resolve("foo"); + fs::create_dir(&dir).unwrap(); + let file = dir.join("bar"); + let contents = "bar"; + fs::write(file, contents).unwrap(); + env.run().unwrap(); + let torrent = env.resolve("foo.torrent"); + let bytes = fs::read(torrent).unwrap(); + let metainfo = serde_bencode::de::from_bytes::(&bytes).unwrap(); + assert_eq!(metainfo.info.pieces, Sha1::from(contents).digest().bytes()); + match metainfo.info.mode { + Mode::Multiple { files } => { + assert_eq!( + files, + &[FileInfo { + length: 3, + md5sum: Some("37b51d194a7513e45b56f6524f2d51f2".to_owned()), + path: FilePath::from_components(&["bar"]), + },] + ); + } + _ => panic!("Expected multi-file torrent"), + } + } + + #[test] + fn multiple_one_file_md5_off() { let mut env = environment(&["--input", "foo", "--announce", "http://bar"]); let dir = env.resolve("foo"); fs::create_dir(&dir).unwrap(); @@ -722,12 +750,24 @@ mod tests { let bytes = fs::read(torrent).unwrap(); let metainfo = serde_bencode::de::from_bytes::(&bytes).unwrap(); assert_eq!(metainfo.info.pieces, Sha1::from(contents).digest().bytes()); - assert_eq!(metainfo.info.mode, Mode::Multiple { files: Vec::new() }) + match metainfo.info.mode { + Mode::Multiple { files } => { + assert_eq!( + files, + &[FileInfo { + length: 3, + md5sum: None, + path: FilePath::from_components(&["bar"]), + },] + ); + } + _ => panic!("Expected multi-file torrent"), + } } #[test] fn multiple_three_files() { - let mut env = environment(&["--input", "foo", "--announce", "http://bar"]); + let mut env = environment(&["--input", "foo", "--announce", "http://bar", "--md5sum"]); let dir = env.resolve("foo"); fs::create_dir(&dir).unwrap(); fs::write(dir.join("a"), "abc").unwrap(); @@ -741,7 +781,31 @@ mod tests { metainfo.info.pieces, Sha1::from("abchijxyz").digest().bytes() ); - assert_eq!(metainfo.info.mode, Mode::Multiple { files: Vec::new() }) + match metainfo.info.mode { + Mode::Multiple { files } => { + assert_eq!( + files, + &[ + FileInfo { + length: 3, + md5sum: Some("900150983cd24fb0d6963f7d28e17f72".to_owned()), + path: FilePath::from_components(&["a"]), + }, + FileInfo { + length: 3, + md5sum: Some("857c4402ad934005eae4638a93812bf7".to_owned()), + path: FilePath::from_components(&["h"]), + }, + FileInfo { + length: 3, + md5sum: Some("d16fb36f0911f878998c136191af705e".to_owned()), + path: FilePath::from_components(&["x"]), + }, + ] + ); + } + _ => panic!("Expected multi-file torrent"), + } } #[test] @@ -902,14 +966,14 @@ mod tests { env.run().unwrap(); let have = env.out(); let want = " Name foo - Info Hash 2637812436658f855e99f07c40fe7da5832a7b6d -Torrent Size 165 bytes -Content Size 0 bytes + Info Hash d3432a4b9d18baa413095a70f1e417021ceaca5b +Torrent Size 237 bytes +Content Size 9 bytes Private no Tracker http://bar/ Piece Size 16 KiB Piece Count 1 - File Count 0 + File Count 3 "; assert_eq!(have, want); } diff --git a/src/platform.rs b/src/platform.rs index 60d186f..bcc905d 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -12,6 +12,15 @@ impl PlatformInterface for Platform { OsString::from("start"), ]) } + + fn hidden(path: &Path) -> Result { + use std::os::windows::fs::MetadataExt; + + const HIDDEN_MASK_WIN: u32 = 0x0000_0002; + + let metadata = path.metadata().context(error::Filesystem { path })?; + Ok((metadata.file_attributes() & HIDDEN_MASK_WIN) != 0) + } } #[cfg(target_os = "macos")] @@ -19,6 +28,28 @@ impl PlatformInterface for Platform { fn opener() -> Result, Error> { Ok(vec![OsString::from("open")]) } + + fn hidden(path: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + use std::{ffi::CString, mem}; + + const HIDDEN_MASK_MAC: u32 = 0x0000_8000; + + let mut stat: libc::stat = unsafe { mem::zeroed() }; + + let cpath = CString::new(path.as_os_str().as_bytes()).expect("Path contained null character."); + + let error_code = unsafe { libc::stat(cpath.as_ptr(), &mut stat) }; + + if error_code != 0 { + return Err(Error::Filesystem { + source: io::Error::from_raw_os_error(error_code), + path: path.to_owned(), + }); + } + + Ok(stat.st_flags & HIDDEN_MASK_MAC != 0) + } } #[cfg(not(any(target_os = "windows", target_os = "macos")))] @@ -36,4 +67,8 @@ impl PlatformInterface for Platform { Err(Error::OpenerMissing { tried: OPENERS }) } + + fn hidden(path: &Path) -> Result { + Ok(false) + } } diff --git a/src/platform_interface.rs b/src/platform_interface.rs index a87eb81..1f3256b 100644 --- a/src/platform_interface.rs +++ b/src/platform_interface.rs @@ -32,4 +32,6 @@ pub(crate) trait PlatformInterface { } fn opener() -> Result, Error>; + + fn hidden(path: &Path) -> Result; } diff --git a/src/walker.rs b/src/walker.rs new file mode 100644 index 0000000..08a77ad --- /dev/null +++ b/src/walker.rs @@ -0,0 +1,69 @@ +use crate::common::*; + +pub(crate) struct Walker { + include_junk: bool, + include_hidden: bool, + root: PathBuf, +} + +impl Walker { + pub(crate) fn new(root: &Path) -> Walker { + Walker { + include_junk: false, + include_hidden: false, + root: root.to_owned(), + } + } + + pub(crate) fn _include_junk(self) -> Self { + Walker { + include_junk: true, + ..self + } + } + + pub(crate) fn _include_hidden(self) -> Self { + Walker { + include_hidden: true, + ..self + } + } + + pub(crate) fn files(self) -> Result { + let mut paths = Vec::new(); + let mut total_size = 0; + + let junk: &[&OsStr] = &[OsStr::new("Thumbs.db"), OsStr::new("Desktop.ini")]; + + for result in WalkDir::new(&self.root).sort_by(|a, b| a.file_name().cmp(b.file_name())) { + let entry = result?; + + let path = entry.path(); + + let file_name = entry.file_name(); + + let metadata = entry.metadata()?; + + if !metadata.is_file() { + continue; + } + + if !self.include_hidden && file_name.to_string_lossy().starts_with('.') { + continue; + } + + if !self.include_hidden && Platform::hidden(path)? { + continue; + } + + if !self.include_junk && junk.contains(&file_name) { + continue; + } + + total_size += metadata.len(); + paths.push(entry.path().to_owned()); + } + + Ok(Files::new(self.root, Bytes::from(total_size))) + } +}