#![expect(
clippy::indexing_slicing,
reason = "Image signatures are checked for minimum length before fixed-format byte access."
)]
use anyhow::{Context, Result};
use base64::Engine;
use std::path::Path;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ImageData {
pub base64_data: String,
pub mime_type: String,
pub file_path: String,
pub size: u64,
}
pub fn detect_mime_type_from_content_type(content_type: &str) -> Option<String> {
let content_type = content_type.to_lowercase();
if content_type.starts_with("image/png") {
Some("image/png".to_string())
} else if content_type.starts_with("image/jpeg") || content_type.starts_with("image/jpg") {
Some("image/jpeg".to_string())
} else if content_type.starts_with("image/gif") {
Some("image/gif".to_string())
} else if content_type.starts_with("image/webp") {
Some("image/webp".to_string())
} else if content_type.starts_with("image/bmp") {
Some("image/bmp".to_string())
} else if content_type.starts_with("image/tiff") || content_type.starts_with("image/tif") {
Some("image/tiff".to_string())
} else if content_type.starts_with("image/svg") {
Some("image/svg+xml".to_string())
} else {
None
}
}
pub fn detect_mime_type_from_data(data: &[u8]) -> String {
if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
return "image/jpeg".to_string();
}
if data.len() < 8 {
return "image/png".to_string();
}
match &data[..8] {
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => "image/png".to_string(),
[0x47, 0x49, 0x46, 0x38, _, _, _, _] => {
if data.len() >= 12 && &data[8..12] == b"WEBP" {
"image/webp".to_string()
} else {
"image/gif".to_string()
}
}
[0x52, 0x49, 0x46, 0x46, _, _, _, _] => {
if data.len() >= 12 && &data[8..12] == b"WEBP" {
"image/webp".to_string()
} else {
"image/png".to_string()
}
}
[0x42, 0x4D, _, _] => "image/bmp".to_string(),
_ => "image/png".to_string(),
}
}
fn detect_mime_type_from_extension(path: &Path) -> Result<String> {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
let mime_type = match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" | "tif" => "image/tiff",
"svg" => "image/svg+xml",
_ => return Err(anyhow::anyhow!("Unsupported image format: {extension}")),
};
Ok(mime_type.to_string())
}
pub fn has_supported_image_extension(path: &Path) -> bool {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
const VALID_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "svg"];
VALID_EXTENSIONS.contains(&extension.as_str())
}
pub fn encode_to_base64(data: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(data)
}
pub const MAX_IMAGE_FILE_BYTES: u64 = 20 * 1024 * 1024;
fn image_too_large_error(len: u64) -> anyhow::Error {
anyhow::anyhow!("Image file too large: {len} bytes (max {}MB)", MAX_IMAGE_FILE_BYTES / (1024 * 1024))
}
async fn read_image_file_blocking(path: &Path) -> Result<ImageData> {
let owned_path = path.to_path_buf();
tokio::task::spawn_blocking(move || read_image_file_blocking_inner(&owned_path))
.await
.context("image read task failed")?
}
fn read_image_file_blocking_inner(path: &Path) -> Result<ImageData> {
if let Ok(metadata) = std::fs::metadata(path)
&& metadata.is_file()
&& metadata.len() > MAX_IMAGE_FILE_BYTES
{
return Err(image_too_large_error(metadata.len()));
}
let file_contents =
std::fs::read(path).with_context(|| format!("Failed to read image file: {}", path.display()))?;
if file_contents.len() as u64 > MAX_IMAGE_FILE_BYTES {
return Err(image_too_large_error(file_contents.len() as u64));
}
let mime_type = detect_mime_type_from_extension(path)?;
Ok(ImageData {
base64_data: encode_to_base64(&file_contents),
mime_type,
file_path: path.display().to_string(),
size: file_contents.len() as u64,
})
}
pub async fn read_image_file<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
use crate::paths::is_safe_relative_path;
let path = file_path.as_ref();
if !is_safe_relative_path(&path.to_string_lossy()) {
return Err(anyhow::anyhow!("Unsafe or traversal detected in image path: {}", path.display()));
}
if !has_supported_image_extension(path) {
return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
}
read_image_file_blocking(path).await
}
pub async fn read_image_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
let path = file_path.as_ref();
if !has_supported_image_extension(path) {
return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
}
read_image_file_blocking(path).await
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
const PNG_MAGIC: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
#[tokio::test]
async fn read_image_file_returns_encoded_bytes_and_mime() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("pixel.png");
std::fs::write(&path, PNG_MAGIC).expect("write image");
let data = read_image_file_any_path(&path).await.expect("read image");
assert_eq!(data.mime_type, "image/png");
assert_eq!(data.size, PNG_MAGIC.len() as u64);
let decoded = base64::engine::general_purpose::STANDARD
.decode(&data.base64_data)
.expect("valid base64");
assert_eq!(decoded, PNG_MAGIC);
}
#[tokio::test]
async fn read_image_file_rejects_oversized_file_before_encoding() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("huge.png");
let file = std::fs::File::create(&path).expect("create image");
file.set_len(MAX_IMAGE_FILE_BYTES + 1).expect("set length");
drop(file);
let err = read_image_file_any_path(&path)
.await
.expect_err("oversized image must be rejected");
assert!(err.to_string().contains("too large"), "unexpected error: {err}");
}
#[tokio::test]
async fn read_image_file_any_path_rejects_unsupported_extension() {
let err = read_image_file_any_path("notes.txt")
.await
.expect_err("unsupported extension must be rejected");
assert!(err.to_string().contains("Unsupported image extension"));
}
#[test]
fn detect_mime_type_from_data_recognizes_png_magic() {
assert_eq!(detect_mime_type_from_data(PNG_MAGIC), "image/png");
}
}