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