Support creating multi-file torrents

type: added
This commit is contained in:
Casey Rodarmor 2020-02-05 16:01:44 -08:00
parent 551617de4f
commit 3739a92857
No known key found for this signature in database
GPG Key ID: 556186B153EC6FE0
12 changed files with 300 additions and 39 deletions

View File

@ -12,7 +12,7 @@ pub(crate) use std::{
io::{self, Read, Write}, io::{self, Read, Write},
num::ParseFloatError, num::ParseFloatError,
ops::{Div, DivAssign, Mul, MulAssign}, ops::{Div, DivAssign, Mul, MulAssign},
path::{Path, PathBuf}, path::{self, Path, PathBuf},
process::{self, Command, ExitStatus}, process::{self, Command, ExitStatus},
str::{self, FromStr}, str::{self, FromStr},
time::{SystemTime, SystemTimeError}, time::{SystemTime, SystemTimeError},
@ -46,10 +46,10 @@ pub(crate) use crate::{
// structs and enums // structs and enums
pub(crate) use crate::{ pub(crate) use crate::{
bytes::Bytes, env::Env, error::Error, file_info::FileInfo, files::Files, hasher::Hasher, bytes::Bytes, env::Env, error::Error, file_info::FileInfo, file_path::FilePath, files::Files,
info::Info, lint::Lint, linter::Linter, metainfo::Metainfo, mode::Mode, opt::Opt, hasher::Hasher, info::Info, lint::Lint, linter::Linter, metainfo::Metainfo, mode::Mode, opt::Opt,
piece_length_picker::PieceLengthPicker, platform::Platform, style::Style, table::Table, 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 // test stdlib types

View File

@ -37,6 +37,35 @@ pub(crate) enum Error {
Filesystem { source: io::Error, path: PathBuf }, Filesystem { source: io::Error, path: PathBuf },
#[snafu(display("Failed to find opener utility, please install one of {}", tried.join(",")))] #[snafu(display("Failed to find opener utility, please install one of {}", tried.join(",")))]
OpenerMissing { tried: &'static [&'static str] }, 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( #[snafu(display(
"Piece length `{}` too large. The maximum supported piece length is {}.", "Piece length `{}` too large. The maximum supported piece length is {}.",
bytes, bytes,

View File

@ -1,8 +1,8 @@
use crate::common::*; use crate::common::*;
#[derive(Deserialize, Serialize, Debug, PartialEq)] #[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct FileInfo { pub(crate) struct FileInfo {
pub length: u64, pub(crate) length: u64,
pub md5sum: Option<String>, pub(crate) md5sum: Option<String>,
pub path: Vec<String>, pub(crate) path: FilePath,
} }

58
src/file_path.rs Normal file
View File

@ -0,0 +1,58 @@
use crate::common::*;
#[serde(transparent)]
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub(crate) struct FilePath {
components: Vec<String>,
}
impl FilePath {
pub(crate) fn from_prefix_and_path(prefix: &Path, path: &Path) -> Result<FilePath, Error> {
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<String> = components
.iter()
.cloned()
.map(|component| component.to_owned())
.collect();
assert!(!components.is_empty());
FilePath { components }
}
}

View File

@ -1,26 +1,17 @@
use crate::common::*; use crate::common::*;
pub(crate) struct Files { pub(crate) struct Files {
root: PathBuf,
total_size: Bytes, total_size: Bytes,
} }
impl Files { impl Files {
pub(crate) fn from_root(root: &Path) -> Result<Files, Error> { pub(crate) fn new(root: PathBuf, total_size: Bytes) -> Files {
let mut total_size = 0; Files { root, total_size }
}
for result in WalkDir::new(root).sort_by(|a, b| a.file_name().cmp(b.file_name())) { pub(crate) fn root(&self) -> &Path {
let entry = result?; &self.root
let metadata = entry.metadata()?;
if metadata.is_file() {
total_size += metadata.len();
}
}
Ok(Files {
total_size: Bytes::from(total_size),
})
} }
pub(crate) fn total_size(&self) -> Bytes { pub(crate) fn total_size(&self) -> Bytes {

View File

@ -12,11 +12,11 @@ pub(crate) struct Hasher {
impl Hasher { impl Hasher {
pub(crate) fn hash( pub(crate) fn hash(
root: &Path, files: &Files,
md5sum: bool, md5sum: bool,
piece_length: u32, piece_length: u32,
) -> Result<(Mode, Vec<u8>), Error> { ) -> Result<(Mode, Vec<u8>), 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 { fn new(md5sum: bool, piece_length: u32) -> Self {
@ -64,17 +64,28 @@ impl Hasher {
} }
fn hash_dir(&mut self, dir: &Path) -> Result<Vec<FileInfo>, Error> { fn hash_dir(&mut self, dir: &Path) -> Result<Vec<FileInfo>, Error> {
let mut files = Vec::new();
for result in WalkDir::new(dir).sort_by(|a, b| a.file_name().cmp(b.file_name())) { for result in WalkDir::new(dir).sort_by(|a, b| a.file_name().cmp(b.file_name())) {
let entry = result?; let entry = result?;
let path = entry.path(); let path = entry.path();
if entry.metadata()?.is_file() { if !entry.metadata()?.is_file() {
let (_md5sum, _length) = self.hash_file(path)?; 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<md5::Digest>, u64), Error> { fn hash_file(&mut self, file: &Path) -> Result<(Option<md5::Digest>, u64), Error> {

View File

@ -60,6 +60,7 @@ mod consts;
mod env; mod env;
mod error; mod error;
mod file_info; mod file_info;
mod file_path;
mod files; mod files;
mod hasher; mod hasher;
mod info; mod info;
@ -80,6 +81,7 @@ mod table;
mod target; mod target;
mod torrent_summary; mod torrent_summary;
mod use_color; mod use_color;
mod walker;
fn main() { fn main() {
if let Err(code) = Env::main().status() { if let Err(code) = Env::main().status() {

View File

@ -2,7 +2,7 @@ use crate::common::*;
#[derive(Deserialize, Serialize, Debug, PartialEq)] #[derive(Deserialize, Serialize, Debug, PartialEq)]
#[serde(untagged)] #[serde(untagged)]
pub enum Mode { pub(crate) enum Mode {
Single { length: u64, md5sum: Option<String> }, Single { length: u64, md5sum: Option<String> },
Multiple { files: Vec<FileInfo> }, Multiple { files: Vec<FileInfo> },
} }

View File

@ -123,7 +123,7 @@ impl Create {
pub(crate) fn run(self, env: &mut Env) -> Result<(), Error> { pub(crate) fn run(self, env: &mut Env) -> Result<(), Error> {
let input = env.resolve(&self.input); let input = env.resolve(&self.input);
let files = Files::from_root(&input)?; let files = Walker::new(&input).files()?;
let piece_length = self let piece_length = self
.piece_length .piece_length
@ -209,7 +209,7 @@ impl Create {
Some(String::from(consts::CREATED_BY_DEFAULT)) 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 { let info = Info {
source: self.source, source: self.source,
@ -710,7 +710,35 @@ mod tests {
} }
#[test] #[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::<Metainfo>(&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 mut env = environment(&["--input", "foo", "--announce", "http://bar"]);
let dir = env.resolve("foo"); let dir = env.resolve("foo");
fs::create_dir(&dir).unwrap(); fs::create_dir(&dir).unwrap();
@ -722,12 +750,24 @@ mod tests {
let bytes = fs::read(torrent).unwrap(); let bytes = fs::read(torrent).unwrap();
let metainfo = serde_bencode::de::from_bytes::<Metainfo>(&bytes).unwrap(); let metainfo = serde_bencode::de::from_bytes::<Metainfo>(&bytes).unwrap();
assert_eq!(metainfo.info.pieces, Sha1::from(contents).digest().bytes()); 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] #[test]
fn multiple_three_files() { 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"); let dir = env.resolve("foo");
fs::create_dir(&dir).unwrap(); fs::create_dir(&dir).unwrap();
fs::write(dir.join("a"), "abc").unwrap(); fs::write(dir.join("a"), "abc").unwrap();
@ -741,7 +781,31 @@ mod tests {
metainfo.info.pieces, metainfo.info.pieces,
Sha1::from("abchijxyz").digest().bytes() 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] #[test]
@ -902,14 +966,14 @@ mod tests {
env.run().unwrap(); env.run().unwrap();
let have = env.out(); let have = env.out();
let want = " Name foo let want = " Name foo
Info Hash 2637812436658f855e99f07c40fe7da5832a7b6d Info Hash d3432a4b9d18baa413095a70f1e417021ceaca5b
Torrent Size 165 bytes Torrent Size 237 bytes
Content Size 0 bytes Content Size 9 bytes
Private no Private no
Tracker http://bar/ Tracker http://bar/
Piece Size 16 KiB Piece Size 16 KiB
Piece Count 1 Piece Count 1
File Count 0 File Count 3
"; ";
assert_eq!(have, want); assert_eq!(have, want);
} }

View File

@ -12,6 +12,15 @@ impl PlatformInterface for Platform {
OsString::from("start"), OsString::from("start"),
]) ])
} }
fn hidden(path: &Path) -> Result<bool, Error> {
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")] #[cfg(target_os = "macos")]
@ -19,6 +28,28 @@ impl PlatformInterface for Platform {
fn opener() -> Result<Vec<OsString>, Error> { fn opener() -> Result<Vec<OsString>, Error> {
Ok(vec![OsString::from("open")]) Ok(vec![OsString::from("open")])
} }
fn hidden(path: &Path) -> Result<bool, Error> {
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")))] #[cfg(not(any(target_os = "windows", target_os = "macos")))]
@ -36,4 +67,8 @@ impl PlatformInterface for Platform {
Err(Error::OpenerMissing { tried: OPENERS }) Err(Error::OpenerMissing { tried: OPENERS })
} }
fn hidden(path: &Path) -> Result<bool, Error> {
Ok(false)
}
} }

View File

@ -32,4 +32,6 @@ pub(crate) trait PlatformInterface {
} }
fn opener() -> Result<Vec<OsString>, Error>; fn opener() -> Result<Vec<OsString>, Error>;
fn hidden(path: &Path) -> Result<bool, Error>;
} }

69
src/walker.rs Normal file
View File

@ -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<Files, Error> {
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)))
}
}