1use std::sync::Arc;
10use url::Url;
11
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct UrlInfo {
19 pub size: u64,
21 #[cfg_attr(
23 feature = "sanitize-filename",
24 doc = "Use the [`UrlInfo::filename()`] method to sanitize the filename"
25 )]
26 #[cfg_attr(
27 not(feature = "sanitize-filename"),
28 doc = "Enable the `sanitize-filename` feature to use the `filename()` method for sanitization."
29 )]
30 pub raw_name: String,
31 pub supports_range: bool,
33 pub fast_download: bool,
35 pub final_url: Url,
37 pub file_id: FileId,
39 pub content_type: Option<String>,
41}
42
43#[cfg(feature = "sanitize-filename")]
44impl UrlInfo {
45 #[must_use]
46 pub fn filename(&self) -> String {
47 path_helper::sanitize_filename(&self.raw_name, 255)
48 }
49}
50
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct FileId {
59 pub etag: Option<Arc<str>>,
61 pub last_modified: Option<Arc<str>>,
63}
64
65impl FileId {
66 pub fn new(etag: Option<&str>, last_modified: Option<&str>) -> Self {
68 Self {
69 etag: etag.map(Arc::from),
70 last_modified: last_modified.map(Arc::from),
71 }
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 #![allow(clippy::unwrap_used)]
78 use super::*;
79
80 #[test]
81 fn file_id_new() {
82 let f = FileId::new(Some("abc"), Some("def"));
83 assert_eq!(f.etag, Some(Arc::from("abc")));
84 assert_eq!(f.last_modified, Some(Arc::from("def")));
85 }
86
87 #[test]
88 fn file_id_none_fields() {
89 let f = FileId::new(None, None);
90 assert_eq!(f.etag, None);
91 assert_eq!(f.last_modified, None);
92 }
93
94 #[test]
95 fn file_id_equality() {
96 assert_eq!(FileId::new(Some("x"), None), FileId::new(Some("x"), None));
97 assert_ne!(
98 FileId::new(Some("x"), None),
99 FileId::new(Some("x"), Some("y"))
100 );
101 }
102
103 #[test]
104 fn url_info_filename_sanitizes() {
105 #![allow(unused_variables)]
106 let info = UrlInfo {
107 size: 10,
108 raw_name: "a/b:c*?.txt".to_string(),
109 supports_range: true,
110 fast_download: true,
111 final_url: Url::parse("http://example.com/x").unwrap(),
112 file_id: FileId::default(),
113 content_type: Some("text/plain".to_string()),
114 };
115 #[cfg(feature = "sanitize-filename")]
116 {
117 let name = info.filename();
118 assert_ne!(name, "");
119 assert!(!name.contains(['/', ':', '*', '?']));
120 }
121 }
122
123 #[test]
128 #[cfg(feature = "sanitize-filename")]
129 fn filename_long_utf8_is_not_split() {
130 let info = UrlInfo {
131 size: 0,
132 raw_name: format!("{}.mp4", "中文文件名".repeat(100)),
133 supports_range: false,
134 fast_download: false,
135 final_url: Url::parse("http://example.com/x").unwrap(),
136 file_id: FileId::default(),
137 content_type: None,
138 };
139 let name = info.filename();
140 assert!(
141 std::path::Path::new(&name)
142 .extension()
143 .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")),
144 "extension lost: {name}"
145 );
146 assert_ne!(name, "");
147 assert!(
148 !name.contains('\u{fffd}'),
149 "replacement char indicates a split code point: {name}"
150 );
151 }
152
153 #[test]
158 #[cfg(feature = "sanitize-filename")]
159 fn filename_empty_raw_name_yields_empty() {
160 let info = UrlInfo {
161 size: 0,
162 raw_name: String::new(),
163 supports_range: false,
164 fast_download: false,
165 final_url: Url::parse("http://example.com/x").unwrap(),
166 file_id: FileId::default(),
167 content_type: None,
168 };
169 assert_eq!(info.filename(), "");
170 }
171}