rusty_cat/binary/
binary_task.rs1use 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#[derive(Clone, Debug)]
9pub struct BinaryTask {
10 url: String,
11 headers: HeaderMap,
12 max_body_bytes: Option<u64>,
13}
14
15impl BinaryTask {
16 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 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
27 self.headers = headers;
28 self
29 }
30
31 pub fn with_header(mut self, name: reqwest::header::HeaderName, value: HeaderValue) -> Self {
33 self.headers.insert(name, value);
34 self
35 }
36
37 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 pub fn url(&self) -> &str {
45 &self.url
46 }
47
48 pub fn headers(&self) -> &HeaderMap {
50 &self.headers
51 }
52
53 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#[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 pub fn bytes(&self) -> &Bytes {
103 &self.bytes
104 }
105
106 pub fn content_type(&self) -> Option<&HeaderValue> {
108 self.content_type.as_ref()
109 }
110
111 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}