Support creating multi-file torrents
type: added
This commit is contained in:
parent
551617de4f
commit
3739a92857
|
@ -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
|
||||
|
|
29
src/error.rs
29
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,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use crate::common::*;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq)]
|
||||
pub struct FileInfo {
|
||||
pub length: u64,
|
||||
pub md5sum: Option<String>,
|
||||
pub path: Vec<String>,
|
||||
pub(crate) struct FileInfo {
|
||||
pub(crate) length: u64,
|
||||
pub(crate) md5sum: Option<String>,
|
||||
pub(crate) path: FilePath,
|
||||
}
|
||||
|
|
58
src/file_path.rs
Normal file
58
src/file_path.rs
Normal 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 }
|
||||
}
|
||||
}
|
19
src/files.rs
19
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<Files, Error> {
|
||||
let mut total_size = 0;
|
||||
|
||||
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();
|
||||
}
|
||||
pub(crate) fn new(root: PathBuf, total_size: Bytes) -> Files {
|
||||
Files { root, total_size }
|
||||
}
|
||||
|
||||
Ok(Files {
|
||||
total_size: Bytes::from(total_size),
|
||||
})
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub(crate) fn total_size(&self) -> Bytes {
|
||||
|
|
|
@ -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<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 {
|
||||
|
@ -64,17 +64,28 @@ impl Hasher {
|
|||
}
|
||||
|
||||
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())) {
|
||||
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;
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
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(files)
|
||||
}
|
||||
|
||||
fn hash_file(&mut self, file: &Path) -> Result<(Option<md5::Digest>, u64), Error> {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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<String> },
|
||||
Multiple { files: Vec<FileInfo> },
|
||||
}
|
||||
|
|
|
@ -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::<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 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::<Metainfo>(&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);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,15 @@ impl PlatformInterface for Platform {
|
|||
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")]
|
||||
|
@ -19,6 +28,28 @@ impl PlatformInterface for Platform {
|
|||
fn opener() -> Result<Vec<OsString>, Error> {
|
||||
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")))]
|
||||
|
@ -36,4 +67,8 @@ impl PlatformInterface for Platform {
|
|||
|
||||
Err(Error::OpenerMissing { tried: OPENERS })
|
||||
}
|
||||
|
||||
fn hidden(path: &Path) -> Result<bool, Error> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,4 +32,6 @@ pub(crate) trait PlatformInterface {
|
|||
}
|
||||
|
||||
fn opener() -> Result<Vec<OsString>, Error>;
|
||||
|
||||
fn hidden(path: &Path) -> Result<bool, Error>;
|
||||
}
|
||||
|
|
69
src/walker.rs
Normal file
69
src/walker.rs
Normal 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)))
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user