Skip to main content

xmtp_configuration/common/
attachments.rs

1//! Checks for attachment storage configuration shared by the backend and client.
2
3use std::net::{Ipv4Addr, Ipv6Addr};
4
5use super::server::MAX_PUBLISHED_VALUE;
6
7/// Default maximum ciphertext size for one attachment upload, in bytes.
8pub const BACKEND_DEFAULT_MAX_UPLOAD_BYTES: u64 = 104_857_600;
9/// Longest ciphertext, tag included, accepted by in-memory decryption, in bytes.
10/// Equals the default download bound when attachment storage is not offered and
11/// [`BACKEND_DEFAULT_MAX_UPLOAD_BYTES`].
12pub const MAX_IN_MEMORY_DECRYPT_BYTES: u64 = 104_857_600;
13/// Largest attachment `contentLength` the content type can carry.
14pub const MAX_ATTACHMENT_UPLOAD_BYTES: u64 = u32::MAX as u64;
15/// Largest retention value an SDK integer can carry exactly.
16pub const MAX_ATTACHMENT_RETENTION_SECONDS: u64 = MAX_PUBLISHED_VALUE;
17
18/// An attachment configuration field and the reason it cannot be used.
19#[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
98/// Check the published base URL without replacing its raw host, port, or path.
99// implements: ATCH-008
100pub 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
195/// Check the effective upload limit after a missing wire value takes its default.
196// implements: ATCH-008
197pub 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
206/// Check a configured or published retention value.
207// implements: ATCH-008
208pub 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;