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/// Largest attachment `contentLength` the content type can carry.
10pub const MAX_ATTACHMENT_UPLOAD_BYTES: u64 = u32::MAX as u64;
11/// Largest retention value an SDK integer can carry exactly.
12pub const MAX_ATTACHMENT_RETENTION_SECONDS: u64 = MAX_PUBLISHED_VALUE;
13
14/// An attachment configuration field and the reason it cannot be used.
15#[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
94/// Check the published base URL without replacing its raw host, port, or path.
95// implements: ATCH-008
96pub 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
191/// Check the effective upload limit after a missing wire value takes its default.
192// implements: ATCH-008
193pub 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
202/// Check a configured or published retention value.
203// implements: ATCH-008
204pub 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;