Skip to main content

adler_core/
avatar.rs

1//! Privacy-safe avatar perceptual hashing helpers.
2//!
3//! Adler never stores raw avatar bytes. The fetch helper reads a bounded
4//! image response, computes a deterministic 64-bit difference hash, and returns
5//! only the normalized hash string.
6
7use std::time::Duration;
8
9use image::imageops::FilterType;
10use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
11use thiserror::Error;
12
13/// Stable prefix for Adler's current avatar perceptual hash algorithm.
14pub const AVATAR_HASH_ALGORITHM: &str = "dhash64_v1";
15
16/// Default maximum response body size for avatar hash fetches.
17pub const DEFAULT_AVATAR_HASH_MAX_BYTES: usize = 1_000_000;
18
19/// Default request timeout for avatar hash fetches.
20pub const DEFAULT_AVATAR_HASH_TIMEOUT: Duration = Duration::from_secs(5);
21
22/// Bounded fetch/decode options for avatar hashing.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct AvatarHashOptions {
25    /// Maximum decoded HTTP response body bytes Adler will read.
26    pub max_bytes: usize,
27    /// Per-request timeout applied to the avatar image fetch.
28    pub timeout: Duration,
29}
30
31impl Default for AvatarHashOptions {
32    fn default() -> Self {
33        Self {
34            max_bytes: DEFAULT_AVATAR_HASH_MAX_BYTES,
35            timeout: DEFAULT_AVATAR_HASH_TIMEOUT,
36        }
37    }
38}
39
40/// Error returned while fetching or hashing an avatar image.
41#[derive(Debug, Error)]
42pub enum AvatarHashError {
43    /// Avatar URL could not be parsed.
44    #[error("invalid avatar URL: {0}")]
45    InvalidUrl(#[from] url::ParseError),
46    /// Only HTTP(S) avatar fetches are supported.
47    #[error("avatar URL must use http or https")]
48    UnsupportedScheme,
49    /// The server returned a non-success HTTP status.
50    #[error("avatar response was not successful: {0}")]
51    Status(reqwest::StatusCode),
52    /// Response content type was absent or outside the image allowlist.
53    #[error("avatar response content type {0:?} is not allowed")]
54    UnsupportedContentType(Option<String>),
55    /// Response advertised or exceeded the configured size limit.
56    #[error("avatar response exceeded {max_bytes} bytes")]
57    TooLarge {
58        /// Maximum allowed response size.
59        max_bytes: usize,
60    },
61    /// HTTP client error.
62    #[error("avatar fetch failed: {0}")]
63    Request(#[from] reqwest::Error),
64    /// Image decoder error.
65    #[error("avatar image decode failed: {0}")]
66    Decode(#[from] image::ImageError),
67}
68
69/// Fetch an avatar image through a caller-configured client and return its
70/// normalized perceptual hash.
71///
72/// Redirect policy, proxying, and TLS policy come from the supplied
73/// `reqwest::Client`. This helper adds URL scheme validation, content-type
74/// allowlisting, timeout, and body-size enforcement.
75pub async fn fetch_avatar_hash(
76    client: &reqwest::Client,
77    url: &str,
78    options: AvatarHashOptions,
79) -> Result<String, AvatarHashError> {
80    let parsed = url::Url::parse(url)?;
81    if !matches!(parsed.scheme(), "http" | "https") {
82        return Err(AvatarHashError::UnsupportedScheme);
83    }
84
85    let mut response = client.get(parsed).timeout(options.timeout).send().await?;
86    let status = response.status();
87    if !status.is_success() {
88        return Err(AvatarHashError::Status(status));
89    }
90
91    let content_type = normalized_content_type(response.headers().get(CONTENT_TYPE));
92    if !content_type
93        .as_deref()
94        .is_some_and(allowed_avatar_content_type)
95    {
96        return Err(AvatarHashError::UnsupportedContentType(content_type));
97    }
98
99    if advertised_length(response.headers().get(CONTENT_LENGTH))
100        .is_some_and(|length| length > options.max_bytes)
101    {
102        return Err(AvatarHashError::TooLarge {
103            max_bytes: options.max_bytes,
104        });
105    }
106
107    let mut bytes = Vec::new();
108    while let Some(chunk) = response.chunk().await? {
109        let next_len = bytes.len().saturating_add(chunk.len());
110        if next_len > options.max_bytes {
111            return Err(AvatarHashError::TooLarge {
112                max_bytes: options.max_bytes,
113            });
114        }
115        bytes.extend_from_slice(&chunk);
116    }
117
118    avatar_hash_from_bytes(&bytes)
119}
120
121/// Compute Adler's normalized avatar perceptual hash from already-fetched
122/// image bytes.
123pub fn avatar_hash_from_bytes(bytes: &[u8]) -> Result<String, AvatarHashError> {
124    let image = image::load_from_memory(bytes)?;
125    let gray = image.resize_exact(9, 8, FilterType::Triangle).to_luma8();
126
127    let mut bits = 0_u64;
128    for y in 0..8 {
129        for x in 0..8 {
130            let left = gray.get_pixel(x, y)[0];
131            let right = gray.get_pixel(x + 1, y)[0];
132            let index = y * 8 + x;
133            if left > right {
134                bits |= 1_u64 << index;
135            }
136        }
137    }
138
139    Ok(format!("{AVATAR_HASH_ALGORITHM}:{bits:016x}"))
140}
141
142fn normalized_content_type(value: Option<&reqwest::header::HeaderValue>) -> Option<String> {
143    value
144        .and_then(|value| value.to_str().ok())
145        .map(|value| value.split(';').next().unwrap_or(value).trim())
146        .filter(|value| !value.is_empty())
147        .map(str::to_ascii_lowercase)
148}
149
150fn allowed_avatar_content_type(content_type: &str) -> bool {
151    matches!(
152        content_type,
153        "image/gif" | "image/jpeg" | "image/png" | "image/webp"
154    )
155}
156
157fn advertised_length(value: Option<&reqwest::header::HeaderValue>) -> Option<usize> {
158    value
159        .and_then(|value| value.to_str().ok())
160        .and_then(|value| value.parse().ok())
161}
162
163#[cfg(test)]
164mod tests {
165    use std::io::Cursor;
166
167    use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
168    use wiremock::matchers::{method, path};
169    use wiremock::{Mock, MockServer, ResponseTemplate};
170
171    use super::*;
172
173    fn png_bytes(pattern: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
174        let image = RgbImage::from_fn(16, 16, |x, y| Rgb(pattern(x, y)));
175        let mut cursor = Cursor::new(Vec::new());
176        DynamicImage::ImageRgb8(image)
177            .write_to(&mut cursor, ImageFormat::Png)
178            .unwrap();
179        cursor.into_inner()
180    }
181
182    #[test]
183    fn same_image_produces_same_hash() {
184        let image = png_bytes(|x, y| {
185            if (x + y) % 2 == 0 {
186                [255, 255, 255]
187            } else {
188                [0, 0, 0]
189            }
190        });
191
192        let left = avatar_hash_from_bytes(&image).unwrap();
193        let right = avatar_hash_from_bytes(&image).unwrap();
194
195        assert_eq!(left, right);
196        assert!(left.starts_with("dhash64_v1:"));
197    }
198
199    #[test]
200    fn visibly_different_images_produce_different_hashes() {
201        let vertical = png_bytes(|x, _| if x < 8 { [255, 255, 255] } else { [0, 0, 0] });
202        let horizontal = png_bytes(|_, y| if y < 8 { [255, 255, 255] } else { [0, 0, 0] });
203
204        let vertical_hash = avatar_hash_from_bytes(&vertical).unwrap();
205        let horizontal_hash = avatar_hash_from_bytes(&horizontal).unwrap();
206
207        assert_ne!(vertical_hash, horizontal_hash);
208    }
209
210    #[tokio::test]
211    async fn fetch_rejects_non_image_responses() {
212        let server = MockServer::start().await;
213        Mock::given(method("GET"))
214            .and(path("/avatar.txt"))
215            .respond_with(
216                ResponseTemplate::new(200)
217                    .insert_header("content-type", "text/plain")
218                    .set_body_string("not an image"),
219            )
220            .mount(&server)
221            .await;
222
223        let client = reqwest::Client::new();
224        let err = fetch_avatar_hash(
225            &client,
226            &format!("{}/avatar.txt", server.uri()),
227            AvatarHashOptions::default(),
228        )
229        .await
230        .unwrap_err();
231
232        assert!(matches!(err, AvatarHashError::UnsupportedContentType(_)));
233    }
234
235    #[tokio::test]
236    async fn fetch_rejects_oversized_responses_before_decoding() {
237        let server = MockServer::start().await;
238        Mock::given(method("GET"))
239            .and(path("/avatar.png"))
240            .respond_with(
241                ResponseTemplate::new(200)
242                    .insert_header("content-type", "image/png")
243                    .insert_header("content-length", "100")
244                    .set_body_bytes(vec![0_u8; 100]),
245            )
246            .mount(&server)
247            .await;
248
249        let client = reqwest::Client::new();
250        let err = fetch_avatar_hash(
251            &client,
252            &format!("{}/avatar.png", server.uri()),
253            AvatarHashOptions {
254                max_bytes: 16,
255                timeout: Duration::from_secs(1),
256            },
257        )
258        .await
259        .unwrap_err();
260
261        assert!(matches!(err, AvatarHashError::TooLarge { max_bytes: 16 }));
262    }
263}