use crate::prelude::*;
use std::fs::read;
use unicode_normalization::is_nfc;
#[injectable]
pub(crate) struct TorrentAuditor {
options: Ref<AuditOptions>,
}
impl TorrentAuditor {
#[cfg(test)]
pub(crate) fn new(options: AuditOptions) -> Self {
Self {
options: Ref::new(options),
}
}
#[cfg(test)]
pub(crate) fn mock() -> Self {
Self::new(AuditOptions::default())
}
pub(crate) fn execute_path(&self, path: &Path) -> AuditItem {
let bytes = match read(path) {
Ok(bytes) => bytes,
Err(e) => {
warn!("Failed to read torrent file: {e}");
return AuditItem {
path: path.to_path_buf(),
issues: Some(vec![AuditIssue::from(AuditIssueKind::ReadFile)]),
..AuditItem::default()
};
}
};
let mut item = self.execute_bytes(&bytes);
item.path = path.to_path_buf();
item
}
pub(crate) fn execute_bytes(&self, bytes: &[u8]) -> AuditItem {
let torrent = match TorrentParser::parse_bytes(bytes) {
Ok(torrent) => torrent,
Err(kind) => {
return AuditItem {
issues: Some(vec![AuditIssue::from(kind)]),
..AuditItem::default()
};
}
};
let mut issues = Vec::new();
if !self.options.ignore_single_file && torrent.paths.is_empty() {
issues.push(AuditIssue::from(AuditIssueKind::NoFiles));
}
if !self.options.ignore_non_utf8 {
audit_non_utf8(&torrent.name, &mut issues);
for parts in &torrent.paths {
for part in parts {
audit_non_utf8(part, &mut issues);
}
}
}
if !self.options.ignore_broken_extension {
audit_broken_extension(&torrent.paths, &mut issues);
}
if !issues.is_empty() {
return AuditItem {
issues: Some(issues),
..AuditItem::from(torrent)
};
}
self.audit_value(&torrent.name, &mut issues);
for parts in &torrent.paths {
for part in parts {
self.audit_value(part, &mut issues);
}
}
AuditItem {
issues: (!issues.is_empty()).then_some(issues),
..AuditItem::from(torrent)
}
}
fn audit_value(&self, decoded: &DecodedString, issues: &mut Vec<AuditIssue>) {
let value = match decoded {
DecodedString::Known(value) => value,
DecodedString::Suggestions(_, _) => {
return;
}
};
let restricted = Sanitizer::restricted().execute(value.to_owned());
if !restricted.found.is_empty() {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::Restricted),
raw: Some(RawString::from(value)),
sanitized: Some(restricted.found),
..AuditIssue::default()
});
}
if !self.options.ignore_invisible {
let invisible = Sanitizer::invisible().execute(value.to_owned());
if !invisible.found.is_empty() {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::Invisible),
raw: Some(RawString::from(value)),
sanitized: Some(invisible.found),
..AuditIssue::default()
});
}
}
if !self.options.ignore_directional && !value.contains_rtl() {
let directional = Sanitizer::directional().execute(value.to_owned());
if !directional.found.is_empty() {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::UnnecessaryDirectional),
raw: Some(RawString::from(value)),
sanitized: Some(directional.found),
..AuditIssue::default()
});
}
}
if !self.options.ignore_libtorrent {
let libtorrent = Sanitizer::libtorrent().execute(value.to_owned());
if !libtorrent.found.is_empty() {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::LibtorrentStripped),
raw: Some(RawString::from(value)),
sanitized: Some(libtorrent.found),
..AuditIssue::default()
});
}
}
if !self.options.ignore_unsafe && !is_single_safe_segment(value) {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::UnsafeSegment),
raw: Some(RawString::from(value)),
..AuditIssue::default()
});
}
if !self.options.ignore_nfd && !is_nfc(value) {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::Decomposed),
raw: Some(RawString::from(value)),
..AuditIssue::default()
});
}
if !self.options.ignore_leading_period && value.starts_with('.') {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::LeadingPeriod),
raw: Some(RawString::from(value)),
..AuditIssue::default()
});
}
if !self.options.ignore_leading_space && value.starts_with(' ') {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::LeadingSpace),
raw: Some(RawString::from(value)),
..AuditIssue::default()
});
}
if !self.options.ignore_trailing_space && value.ends_with(' ') {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::TrailingSpace),
raw: Some(RawString::from(value)),
..AuditIssue::default()
});
}
}
}
fn audit_broken_extension(paths: &[Vec<DecodedString>], issues: &mut Vec<AuditIssue>) {
for parts in paths {
let Some(DecodedString::Suggestions(raw, _)) = parts.last() else {
continue;
};
if broken_extension(raw) {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::BrokenExtension),
raw: Some(RawString::from(raw.clone())),
..AuditIssue::default()
});
}
}
}
fn broken_extension(raw: &[u8]) -> bool {
let Some(extension) = get_extension(raw) else {
return false;
};
let extension = String::from_utf8_lossy(extension);
let libtorrent = LibtorrentDecoder::decode(raw);
!libtorrent.ends_with(extension.as_ref())
}
fn get_extension(raw: &[u8]) -> Option<&[u8]> {
let index = raw.iter().rposition(|&byte| byte == b'.')?;
raw.get(index..)
}
fn audit_non_utf8(decoded: &DecodedString, issues: &mut Vec<AuditIssue>) {
if let DecodedString::Suggestions(raw, suggestions) = decoded {
issues.push(AuditIssue {
kind: AuditIssueKind::Path(AuditPathIssueKind::NonUtf8),
raw: Some(RawString::Bytes(raw.clone())),
suggestions: Some(suggestions.clone()),
..AuditIssue::default()
});
}
}