use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::extractor::Youtube;
pub fn validate_youtube_url(url: &str) -> Result<()> {
tracing::debug!(url = url, "⚙️ Validating YouTube URL");
let parsed = url::Url::parse(url).map_err(|e| Error::url_validation(url, format!("Invalid URL format: {}", e)))?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(Error::url_validation(
url,
format!("Unsafe URL scheme '{}'. Only HTTP and HTTPS are allowed", scheme),
));
}
let host = parsed
.host_str()
.ok_or_else(|| Error::url_validation(url, "URL must have a host"))?;
let is_youtube = Youtube::supports_url(url);
if !is_youtube {
tracing::warn!(url = url, host = host, "URL validation failed: not a YouTube domain");
return Err(Error::url_validation(
url,
format!("URL must be from YouTube (got: {})", host),
));
}
tracing::debug!(url = url, host = host, "✅ YouTube URL validated successfully");
Ok(())
}
pub fn sanitize_path(path: impl Into<PathBuf>) -> Result<PathBuf> {
let path = path.into();
tracing::debug!(
path = ?path,
"⚙️ Sanitizing file path"
);
if path.is_absolute() {
return Err(Error::path_validation(path, "Absolute paths are not allowed"));
}
let mut sanitized = PathBuf::new();
let mut has_parent_ref = false;
for component in path.components() {
match component {
std::path::Component::Normal(part) => {
let part_str = part.to_string_lossy();
if part_str.contains("..") {
let msg = format!("Path contains suspicious component: {}", part_str);
return Err(Error::path_validation(path, msg));
}
sanitized.push(part);
}
std::path::Component::ParentDir => {
has_parent_ref = true;
}
std::path::Component::CurDir => {
}
std::path::Component::RootDir => {
return Err(Error::path_validation(path, "Root directory reference in path"));
}
std::path::Component::Prefix(_) => {
return Err(Error::path_validation(path, "Windows path prefix not allowed"));
}
}
}
if has_parent_ref {
let msg = format!("Path traversal detected (..): {}", path.display());
return Err(Error::path_validation(path, msg));
}
if sanitized.as_os_str().is_empty() {
return Err(Error::path_validation(path, "Empty path after sanitization"));
}
tracing::debug!(
original_path = ?path,
sanitized_path = ?sanitized,
"✅ Path sanitized successfully"
);
Ok(sanitized)
}
pub fn sanitize_filename(filename: &str) -> String {
tracing::debug!(filename = filename, "⚙️ Sanitizing filename");
let mut result = String::with_capacity(filename.len());
let mut prev_dot = false;
for c in filename.chars() {
if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() {
prev_dot = false;
continue;
}
if c == '.' && prev_dot {
result.pop();
prev_dot = false;
continue;
}
prev_dot = c == '.';
result.push(c);
}
let trimmed_len = result.trim().len();
let result = if trimmed_len == 0 {
"download".to_string()
} else if trimmed_len == result.len() {
result
} else {
result.trim().to_string()
};
tracing::debug!(
original_filename = filename,
sanitized_filename = %result,
"✅ Filename sanitized"
);
result
}