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    #[must_use]
46    pub fn filename(&self) -> String {
47        path_helper::sanitize_filename(&self.raw_name, 255)
48    }
49}
50
51/// File identity used for incremental and resumable downloads.
52///
53/// Combines the `ETag` and `Last-Modified` headers into a stable identifier.
54/// Two downloads of the same logical file share a [`FileId`] only if neither
55/// header changed, which lets the engine resume instead of re-downloading.
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct FileId {
59    /// The `ETag` header value, if present.
60    pub etag: Option<Arc<str>>,
61    /// The `Last-Modified` header value, if present.
62    pub last_modified: Option<Arc<str>>,
63}
64
65impl FileId {
66    /// Build a [`FileId`] from borrowed header values, copying them into `Arc<str>`.
67    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    /// Verifies that truncating a long multi-byte (UTF-8) filename never splits
124    /// a character: `path_helper::sanitize_filename` truncates by UTF-16 units on
125    /// Windows and by bytes with a char-boundary fallback on Unix, so the result
126    /// stays valid UTF-8, keeps the extension, and contains no replacement char.
127    #[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    /// Documents the current behavior for an empty `raw_name`: `filename()`
154    /// returns an empty string. In practice `prefetch::get_filename` always falls
155    /// back to the URL host/string so `raw_name` is non-empty, but `filename()`
156    /// itself performs no non-empty guarantee.
157    #[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}