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