Skip to main content

fast_down/
url_info.rs

1//! Resolution of downloadable-resource metadata from an initial HTTP request.
2//!
3//! After a `FastDownPuller` performs a prefetch, it produces a
4//! [`UrlInfo`] describing the resource (size, name, content type, range support)
5//! together with a [`FileId`] (derived from the `ETag` / `Last-Modified`
6//! headers) used to detect when a previously-downloaded file is still valid for
7//! incremental/resumable downloads.
8
9use std::sync::Arc;
10use url::Url;
11
12/// Metadata about a downloadable resource, gathered from the initial HTTP request.
13///
14/// Includes file size, filename, content type, range support, and file identity
15/// (`ETag` / `Last-Modified`) for incremental downloads.
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct UrlInfo {
19    /// Total size of the resource in bytes, taken from the `Content-Length` header (0 if absent).
20    pub size: u64,
21    /// Raw filename returned by the server. Sanitize invalid characters before using it safely.
22    #[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    /// Whether the server supports HTTP range requests (used to split the download into concurrent chunks).
32    pub supports_range: bool,
33    /// Whether the resource can be downloaded with the optimized fast (multi-chunk) path.
34    pub fast_download: bool,
35    /// The URL the response was actually served from, after any redirects.
36    pub final_url: Url,
37    /// Stable identity of the file, used to validate incremental/resumable downloads.
38    pub file_id: FileId,
39    /// The `Content-Type` header value, if the server provided one.
40    pub content_type: Option<String>,
41}
42
43#[cfg(feature = "sanitize-filename")]
44impl UrlInfo {
45    /// The suggested filename, sanitized for filesystem use and truncated to 255.
46    #[must_use]
47    pub fn filename(&self) -> String {
48        path_helper::sanitize_filename(&self.raw_name, 255)
49    }
50}
51
52/// File identity used for incremental and resumable downloads.
53///
54/// Combines the `ETag` and `Last-Modified` headers into a stable identifier.
55/// Two downloads of the same logical file share a [`FileId`] only if neither
56/// header changed, which lets the engine resume instead of re-downloading.
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58#[derive(Debug, Clone, PartialEq, Eq, Default)]
59pub struct FileId {
60    /// The `ETag` header value, if present.
61    pub etag: Option<Arc<str>>,
62    /// The `Last-Modified` header value, if present.
63    pub last_modified: Option<Arc<str>>,
64}
65
66impl FileId {
67    /// Build a [`FileId`] from borrowed header values, copying them into `Arc<str>`.
68    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    /// Verifies that truncating a long multi-byte (UTF-8) filename never splits
125    /// a character: `path_helper::sanitize_filename` truncates by UTF-16 units on
126    /// Windows and by bytes with a char-boundary fallback on Unix, so the result
127    /// stays valid UTF-8, keeps the extension, and contains no replacement char.
128    #[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    /// Documents the current behavior for an empty `raw_name`: `filename()`
155    /// returns an empty string. In practice `prefetch::get_filename` always falls
156    /// back to the URL host/string so `raw_name` is non-empty, but `filename()`
157    /// itself performs no non-empty guarantee.
158    #[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}