xmtp_configuration/common/
attachments.rs1use std::net::{Ipv4Addr, Ipv6Addr};
4
5use super::server::MAX_PUBLISHED_VALUE;
6
7pub const BACKEND_DEFAULT_MAX_UPLOAD_BYTES: u64 = 104_857_600;
9pub const MAX_IN_MEMORY_DECRYPT_BYTES: u64 = 104_857_600;
13pub const MAX_ATTACHMENT_UPLOAD_BYTES: u64 = u32::MAX as u64;
15pub const MAX_ATTACHMENT_RETENTION_SECONDS: u64 = MAX_PUBLISHED_VALUE;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
20pub enum AttachmentConfigurationError {
21 #[error("base_url: {reason}")]
22 BaseUrl { reason: &'static str },
23 #[error("max_upload_bytes: {reason}")]
24 MaxUploadBytes { reason: &'static str },
25 #[error("retention_seconds: {reason}")]
26 RetentionSeconds { reason: &'static str },
27}
28
29impl AttachmentConfigurationError {
30 pub const fn field(&self) -> &'static str {
31 match self {
32 Self::BaseUrl { .. } => "base_url",
33 Self::MaxUploadBytes { .. } => "max_upload_bytes",
34 Self::RetentionSeconds { .. } => "retention_seconds",
35 }
36 }
37
38 pub const fn reason(&self) -> &'static str {
39 match self {
40 Self::BaseUrl { reason }
41 | Self::MaxUploadBytes { reason }
42 | Self::RetentionSeconds { reason } => reason,
43 }
44 }
45}
46
47fn invalid_url(reason: &'static str) -> AttachmentConfigurationError {
48 AttachmentConfigurationError::BaseUrl { reason }
49}
50
51fn is_unreserved(byte: u8) -> bool {
52 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
53}
54
55fn is_sub_delimiter(byte: u8) -> bool {
56 matches!(
57 byte,
58 b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
59 )
60}
61
62fn valid_path(path: &str) -> bool {
63 let mut bytes = path.bytes();
64 while let Some(byte) = bytes.next() {
65 if byte == b'%' {
66 if !bytes.next().is_some_and(|digit| digit.is_ascii_hexdigit())
67 || !bytes.next().is_some_and(|digit| digit.is_ascii_hexdigit())
68 {
69 return false;
70 }
71 } else if !(is_unreserved(byte)
72 || is_sub_delimiter(byte)
73 || matches!(byte, b'/' | b':' | b'@'))
74 {
75 return false;
76 }
77 }
78 true
79}
80
81fn is_dot_segment(segment: &str) -> bool {
82 let mut bytes = segment.bytes();
83 let mut dots = 0;
84 while let Some(byte) = bytes.next() {
85 if byte == b'.'
86 || (byte == b'%'
87 && matches!(bytes.next(), Some(b'2'))
88 && matches!(bytes.next(), Some(b'e' | b'E')))
89 {
90 dots += 1;
91 } else {
92 return false;
93 }
94 }
95 dots == 1 || dots == 2
96}
97
98pub fn check_base_url(base_url: &str) -> Result<(), AttachmentConfigurationError> {
101 if base_url.bytes().any(|byte| {
102 !byte.is_ascii() || byte.is_ascii_control() || byte.is_ascii_whitespace() || byte == b'\\'
103 }) {
104 return Err(invalid_url(
105 "contains non-ASCII, whitespace, control, or backslash",
106 ));
107 }
108
109 let (scheme, authority_and_path) = base_url
110 .split_once("://")
111 .ok_or_else(|| invalid_url("is not an absolute URL with an authority"))?;
112 let is_http = scheme == "http";
113 if !is_http && scheme != "https" {
114 return Err(invalid_url(
115 "scheme is not lowercase https or loopback http",
116 ));
117 }
118 if base_url.contains('?') || base_url.contains('#') {
119 return Err(invalid_url("has a query or fragment"));
120 }
121 if base_url.ends_with('/') {
122 return Err(invalid_url("has a trailing slash"));
123 }
124
125 let authority_end = authority_and_path
126 .find('/')
127 .unwrap_or(authority_and_path.len());
128 let (authority, path) = authority_and_path.split_at(authority_end);
129 if authority.is_empty() {
130 return Err(invalid_url("has no host"));
131 }
132 if authority.contains('@') {
133 return Err(invalid_url("has userinfo"));
134 }
135 if authority.contains('%') {
136 return Err(invalid_url("has an encoded host"));
137 }
138
139 let (host, port, is_ipv6) = if let Some(bracketed) = authority.strip_prefix('[') {
140 let (host, suffix) = bracketed
141 .split_once(']')
142 .ok_or_else(|| invalid_url("has an invalid IP literal"))?;
143 let port = if suffix.is_empty() {
144 None
145 } else {
146 Some(
147 suffix
148 .strip_prefix(':')
149 .ok_or_else(|| invalid_url("has an invalid port"))?,
150 )
151 };
152 (host, port, true)
153 } else {
154 let (host, port) = match authority.split_once(':') {
155 Some((host, port)) => (host, Some(port)),
156 None => (authority, None),
157 };
158 (host, port, false)
159 };
160 if port.is_some_and(|port| {
161 !port.bytes().all(|byte| byte.is_ascii_digit())
162 || (!port.is_empty() && !port.parse::<u16>().is_ok_and(|number| number > 0))
163 }) {
164 return Err(invalid_url("has an invalid port"));
165 }
166
167 let is_loopback = if is_ipv6 {
168 host.parse::<Ipv6Addr>()
169 .map_err(|_| invalid_url("has an invalid IP literal"))?
170 .is_loopback()
171 } else {
172 if host.is_empty()
173 || !host
174 .bytes()
175 .all(|byte| is_unreserved(byte) || is_sub_delimiter(byte))
176 {
177 return Err(invalid_url("has an invalid host"));
178 }
179 host.eq_ignore_ascii_case("localhost")
180 || host
181 .parse::<Ipv4Addr>()
182 .is_ok_and(|address| address.is_loopback())
183 };
184 if is_http && !is_loopback {
185 return Err(invalid_url(
186 "http host is not localhost or a loopback IP literal",
187 ));
188 }
189 if !valid_path(path) || path.split('/').any(is_dot_segment) {
190 return Err(invalid_url("has an invalid or changing path"));
191 }
192 Ok(())
193}
194
195pub fn check_max_upload_bytes(value: u64) -> Result<(), AttachmentConfigurationError> {
198 if value == 0 || value > MAX_ATTACHMENT_UPLOAD_BYTES {
199 return Err(AttachmentConfigurationError::MaxUploadBytes {
200 reason: "must be from 1 through the largest attachment contentLength",
201 });
202 }
203 Ok(())
204}
205
206pub fn check_retention_seconds(value: u64) -> Result<(), AttachmentConfigurationError> {
209 if value > MAX_ATTACHMENT_RETENTION_SECONDS {
210 return Err(AttachmentConfigurationError::RetentionSeconds {
211 reason: "exceeds the largest exact SDK integer",
212 });
213 }
214 Ok(())
215}
216
217#[cfg(test)]
218mod tests;