Skip to main content

rusty_cat/binary/
binary_task.rs

1use bytes::Bytes;
2use reqwest::header::{HeaderMap, HeaderValue};
3
4use crate::binary::binary_download_config::BINARY_ABSOLUTE_MAX_BODY_BYTES;
5use crate::error::{InnerErrorCode, MeowError};
6
7/// One bounded in-memory HTTP GET request.
8#[derive(Clone, Debug)]
9pub struct BinaryTask {
10    url: String,
11    headers: HeaderMap,
12    max_body_bytes: Option<u64>,
13}
14
15impl BinaryTask {
16    /// Creates a task. URL validation happens synchronously during enqueue.
17    pub fn new(url: impl Into<String>) -> Self {
18        Self {
19            url: url.into(),
20            headers: HeaderMap::new(),
21            max_body_bytes: None,
22        }
23    }
24
25    /// Replaces request headers.
26    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
27        self.headers = headers;
28        self
29    }
30
31    /// Adds or replaces one request header.
32    pub fn with_header(mut self, name: reqwest::header::HeaderName, value: HeaderValue) -> Self {
33        self.headers.insert(name, value);
34        self
35    }
36
37    /// Applies a task-specific body limit. It may only tighten the client limit.
38    pub fn with_max_body_bytes(mut self, max_body_bytes: u64) -> Self {
39        self.max_body_bytes = Some(max_body_bytes);
40        self
41    }
42
43    /// Returns the request URL exactly as supplied by the caller.
44    pub fn url(&self) -> &str {
45        &self.url
46    }
47
48    /// Returns request headers.
49    pub fn headers(&self) -> &HeaderMap {
50        &self.headers
51    }
52
53    /// Returns the task-specific body limit, if set.
54    pub fn max_body_bytes(&self) -> Option<u64> {
55        self.max_body_bytes
56    }
57
58    pub(crate) fn validate(&self, global_max: u64) -> Result<reqwest::Url, MeowError> {
59        if self.url.trim().is_empty() {
60            return Err(parameter_error("binary task URL must not be empty"));
61        }
62        let parsed = reqwest::Url::parse(&self.url)
63            .map_err(|_| parameter_error("binary task URL is invalid"))?;
64        if !matches!(parsed.scheme(), "http" | "https") {
65            return Err(parameter_error("binary task URL must use HTTP or HTTPS"));
66        }
67        if !parsed.username().is_empty() || parsed.password().is_some() {
68            return Err(parameter_error("binary task URL must not contain userinfo"));
69        }
70        if let Some(max) = self.max_body_bytes {
71            if max == 0 || max > global_max || max > BINARY_ABSOLUTE_MAX_BODY_BYTES {
72                return Err(parameter_error(
73                    "binary task max_body_bytes must be within the client limit",
74                ));
75            }
76        }
77        Ok(parsed)
78    }
79
80    pub(crate) fn effective_max_body_bytes(&self, global_max: u64) -> u64 {
81        self.max_body_bytes.unwrap_or(global_max)
82    }
83}
84
85/// Successful result of a [`BinaryTask`].
86#[derive(Clone, Debug)]
87#[non_exhaustive]
88pub struct BinaryDownloadOutput {
89    bytes: Bytes,
90    content_type: Option<HeaderValue>,
91}
92
93impl BinaryDownloadOutput {
94    pub(crate) fn new(bytes: Bytes, content_type: Option<HeaderValue>) -> Self {
95        Self {
96            bytes,
97            content_type,
98        }
99    }
100
101    /// Borrows the downloaded body.
102    pub fn bytes(&self) -> &Bytes {
103        &self.bytes
104    }
105
106    /// Borrows the final successful response's `Content-Type` header.
107    pub fn content_type(&self) -> Option<&HeaderValue> {
108        self.content_type.as_ref()
109    }
110
111    /// Moves body and metadata out without copying the body.
112    pub fn into_parts(self) -> (Bytes, Option<HeaderValue>) {
113        (self.bytes, self.content_type)
114    }
115}
116
117fn parameter_error(message: impl Into<String>) -> MeowError {
118    MeowError::from_code(InnerErrorCode::ParameterEmpty, message.into())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn validation_rejects_unsafe_urls_and_limits() {
127        for url in [
128            "",
129            "not a url",
130            "file:///tmp/a",
131            "https://user@example.com/a",
132        ] {
133            assert!(BinaryTask::new(url).validate(1024).is_err(), "{url}");
134        }
135        assert!(BinaryTask::new("https://example.com")
136            .with_max_body_bytes(1025)
137            .validate(1024)
138            .is_err());
139    }
140
141    #[test]
142    fn output_into_parts_preserves_backing_storage() {
143        let bytes = Bytes::from_static(b"binary");
144        let ptr = bytes.as_ptr();
145        let output = BinaryDownloadOutput::new(
146            bytes,
147            Some(HeaderValue::from_static("application/octet-stream")),
148        );
149        let (bytes, content_type) = output.into_parts();
150        assert_eq!(bytes.as_ptr(), ptr);
151        assert_eq!(
152            content_type,
153            Some(HeaderValue::from_static("application/octet-stream"))
154        );
155    }
156}