use std::sync::Arc;
use url::Url;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlInfo {
pub size: u64,
#[cfg_attr(
feature = "sanitize-filename",
doc = "Use the [`UrlInfo::filename()`] method to sanitize the filename"
)]
#[cfg_attr(
not(feature = "sanitize-filename"),
doc = "Enable the `sanitize-filename` feature to use the `filename()` method for sanitization."
)]
pub raw_name: String,
pub supports_range: bool,
pub fast_download: bool,
pub final_url: Url,
pub file_id: FileId,
pub content_type: Option<String>,
}
#[cfg(feature = "sanitize-filename")]
impl UrlInfo {
#[must_use]
pub fn filename(&self) -> String {
path_helper::sanitize_filename(&self.raw_name, 255)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileId {
pub etag: Option<Arc<str>>,
pub last_modified: Option<Arc<str>>,
}
impl FileId {
pub fn new(etag: Option<&str>, last_modified: Option<&str>) -> Self {
Self {
etag: etag.map(Arc::from),
last_modified: last_modified.map(Arc::from),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn file_id_new() {
let f = FileId::new(Some("abc"), Some("def"));
assert_eq!(f.etag, Some(Arc::from("abc")));
assert_eq!(f.last_modified, Some(Arc::from("def")));
}
#[test]
fn file_id_none_fields() {
let f = FileId::new(None, None);
assert_eq!(f.etag, None);
assert_eq!(f.last_modified, None);
}
#[test]
fn file_id_equality() {
assert_eq!(FileId::new(Some("x"), None), FileId::new(Some("x"), None));
assert_ne!(
FileId::new(Some("x"), None),
FileId::new(Some("x"), Some("y"))
);
}
#[test]
fn url_info_filename_sanitizes() {
#![allow(unused_variables)]
let info = UrlInfo {
size: 10,
raw_name: "a/b:c*?.txt".to_string(),
supports_range: true,
fast_download: true,
final_url: Url::parse("http://example.com/x").unwrap(),
file_id: FileId::default(),
content_type: Some("text/plain".to_string()),
};
#[cfg(feature = "sanitize-filename")]
{
let name = info.filename();
assert_ne!(name, "");
assert!(!name.contains(['/', ':', '*', '?']));
}
}
#[test]
#[cfg(feature = "sanitize-filename")]
fn filename_long_utf8_is_not_split() {
let info = UrlInfo {
size: 0,
raw_name: format!("{}.mp4", "中文文件名".repeat(100)),
supports_range: false,
fast_download: false,
final_url: Url::parse("http://example.com/x").unwrap(),
file_id: FileId::default(),
content_type: None,
};
let name = info.filename();
assert!(
std::path::Path::new(&name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")),
"extension lost: {name}"
);
assert_ne!(name, "");
assert!(
!name.contains('\u{fffd}'),
"replacement char indicates a split code point: {name}"
);
}
#[test]
#[cfg(feature = "sanitize-filename")]
fn filename_empty_raw_name_yields_empty() {
let info = UrlInfo {
size: 0,
raw_name: String::new(),
supports_range: false,
fast_download: false,
final_url: Url::parse("http://example.com/x").unwrap(),
file_id: FileId::default(),
content_type: None,
};
assert_eq!(info.filename(), "");
}
}