pub mod chachapoly;
pub mod gcm;
pub mod guarded_key;
pub mod sha256;
pub mod stream;
pub use chachapoly::{wipe, wipe_vec};
pub use guarded_key::GuardedKey;
mod signer;
pub use signer::GuardedSigner;
use argon2::Argon2;
use chachapoly::{ChaCha20Poly1305, NONCE_LEN, TAG_LEN};
use zeroize::Zeroize;
pub async fn hash_pass(password: &str) -> [u8; 32] {
let password = password.to_string();
crate::rt::spawn_blocking(move || {
let salt = b"vectorvectovectvecvev";
let mut output = [0u8; 32];
let params = argon2::Params::new(
150_000, 10, 1, Some(32),
).unwrap();
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
argon2.hash_password_into(password.as_bytes(), salt, &mut output).unwrap();
output
}).await.unwrap()
}
fn seal_framed(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, chachapoly::Error> {
use rand::Rng;
let nonce: [u8; NONCE_LEN] = rand::thread_rng().gen();
let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + TAG_LEN);
out.extend_from_slice(&nonce);
out.extend_from_slice(plaintext);
let tag = ChaCha20Poly1305::new(key).seal_in_place(&nonce, &[], &mut out[NONCE_LEN..])?;
out.extend_from_slice(&tag);
Ok(out)
}
fn open_framed(framed: Vec<u8>, key: &[u8; 32]) -> Option<Vec<u8>> {
open_framed_with(&ChaCha20Poly1305::new(key), framed)
}
fn open_framed_with(cipher: &ChaCha20Poly1305, mut framed: Vec<u8>) -> Option<Vec<u8>> {
let len = cipher.open_framed_in_place(&mut framed).ok()?.len();
framed.copy_within(NONCE_LEN..NONCE_LEN + len, 0);
framed.truncate(len);
Some(framed)
}
fn open_hex(hex_data: &str, key: &[u8; 32]) -> Option<String> {
open_hex_with(&ChaCha20Poly1305::new(key), hex_data)
}
fn open_hex_with(cipher: &ChaCha20Poly1305, hex_data: &str) -> Option<String> {
let plaintext = open_framed_with(cipher, crate::simd::hex::hex_string_to_bytes(hex_data))?;
Some(unsafe { String::from_utf8_unchecked(plaintext) })
}
pub fn encrypt_with_key(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
let framed = seal_framed(plaintext.as_bytes(), key).map_err(|e| format!("Encryption failed: {}", e))?;
Ok(crate::simd::hex::bytes_to_hex_string(&framed))
}
pub fn decrypt_with_key(hex_data: &str, key: &[u8; 32]) -> Result<String, String> {
if hex_data.len() < 2 * NONCE_LEN {
return Err("Ciphertext too short".to_string());
}
open_hex(hex_data, key).ok_or_else(|| "Decryption failed (wrong key or corrupted data)".to_string())
}
pub fn is_encryption_enabled() -> bool {
crate::state::resolve_encryption_enabled_from_db()
}
mod hex {
pub fn encode(bytes: &[u8]) -> String {
crate::simd::hex::bytes_to_hex_string(bytes)
}
pub fn decode(hex: &str) -> Result<Vec<u8>, String> {
if !hex.len().is_multiple_of(2) {
return Err("Odd-length hex string".to_string());
}
crate::simd::hex::hex_string_to_bytes_checked(hex)
.ok_or_else(|| "Invalid hex character".to_string())
}
}
#[derive(Debug)]
pub struct EncryptionParams {
pub key: String, pub nonce: String, }
pub fn generate_encryption_params() -> EncryptionParams {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut key: [u8; 32] = rng.gen();
let nonce: [u8; 16] = rng.gen();
let params = EncryptionParams {
key: hex::encode(&key),
nonce: hex::encode(&nonce),
};
key.iter_mut().for_each(|b| *b = 0); params
}
pub(crate) fn attachment_params(key_hex: &str, nonce_hex: &str, key_error: &str) -> Result<([u8; 32], [u8; 16]), String> {
let mut key_bytes = hex::decode(key_hex).map_err(|e| format!("Invalid key: {}", e))?;
let nonce_bytes = hex::decode(nonce_hex).map_err(|e| format!("Invalid nonce: {}", e))?;
let key: Result<[u8; 32], _> = key_bytes.as_slice().try_into();
key_bytes.zeroize();
let key = key.map_err(|_| key_error.to_string())?;
let nonce: [u8; 16] = nonce_bytes.try_into().map_err(|_| "Invalid nonce length".to_string())?;
Ok((key, nonce))
}
pub fn encrypt_data(data: &[u8], params: &EncryptionParams) -> Result<Vec<u8>, String> {
let (mut key, nonce) = attachment_params(¶ms.key, ¶ms.nonce, "Invalid encryption key")?;
let mut buffer = Vec::with_capacity(data.len() + gcm::TAG_LEN);
buffer.extend_from_slice(data);
let tag = gcm::seal(&key, &nonce, &mut buffer);
key.zeroize();
buffer.extend_from_slice(&tag);
Ok(buffer)
}
pub fn decrypt_data(encrypted_data: &[u8], key_hex: &str, nonce_hex: &str) -> Result<Vec<u8>, String> {
decrypt_data_owned(encrypted_data.to_vec(), key_hex, nonce_hex)
}
pub fn decrypt_data_owned(mut data: Vec<u8>, key_hex: &str, nonce_hex: &str) -> Result<Vec<u8>, String> {
if data.len() < gcm::TAG_LEN {
return Err(format!("Invalid Input: encrypted data too small ({} bytes, minimum 16 bytes required for authentication tag)", data.len()));
}
let (mut key, nonce) = attachment_params(key_hex, nonce_hex, "Invalid decryption key")?;
let body = data.len() - gcm::TAG_LEN;
let tag: [u8; gcm::TAG_LEN] = data[body..].try_into().unwrap();
data.truncate(body);
let opened = gcm::open(&key, &nonce, &mut data, &tag);
key.zeroize();
opened.map_err(|_| "aead::Error".to_string())?;
Ok(data)
}
pub fn sha256_hex(data: &[u8]) -> String {
hex::encode(&sha256::digest(data))
}
pub fn attachment_identity_basis(ox: Option<&str>, nonce: &str, url: &str) -> String {
match ox.filter(|h| !h.is_empty()) {
Some(h) => h.to_string(),
None => {
let mut buf = Vec::with_capacity(nonce.len() + 1 + url.len());
buf.extend_from_slice(nonce.as_bytes());
buf.push(0);
buf.extend_from_slice(url.as_bytes());
sha256_hex(&buf)
}
}
}
pub fn mime_from_extension(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"tiff" | "tif" => "image/tiff",
"dng" => "image/x-adobe-dng",
"cr2" => "image/x-canon-cr2",
"nef" => "image/x-nikon-nef",
"arw" => "image/x-sony-arw",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"flv" => "video/x-flv",
"wmv" => "video/x-ms-wmv",
"mpg" | "mpeg" => "video/mpeg",
"3gp" => "video/3gpp",
"ogv" => "video/ogg",
"ts" => "video/mp2t",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"wma" => "audio/x-ms-wma",
"opus" => "audio/opus",
"pdf" => "application/pdf",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" => "application/vnd.ms-powerpoint",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"odt" => "application/vnd.oasis.opendocument.text",
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
"odp" => "application/vnd.oasis.opendocument.presentation",
"rtf" => "application/rtf",
"txt" => "text/plain",
"md" => "text/markdown",
"csv" => "text/csv",
"json" => "application/json",
"xml" => "application/xml",
"yaml" | "yml" => "application/x-yaml",
"toml" => "application/toml",
"sql" => "application/sql",
"zip" => "application/zip",
"rar" => "application/vnd.rar",
"7z" => "application/x-7z-compressed",
"tar" => "application/x-tar",
"gz" => "application/gzip",
"bz2" => "application/x-bzip2",
"xz" => "application/x-xz",
"iso" => "application/x-iso9660-image",
"dmg" => "application/x-apple-diskimage",
"apk" => "application/vnd.android.package-archive",
"jar" => "application/java-archive",
"xdc" => "application/vnd.webxdc+zip",
"obj" => "model/obj",
"gltf" => "model/gltf+json",
"glb" => "model/gltf-binary",
"stl" => "model/stl",
"dae" => "model/vnd.collada+xml",
"js" => "text/javascript",
"py" => "text/x-python",
"rs" => "text/x-rust",
"go" => "text/x-go",
"java" => "text/x-java",
"c" => "text/x-c",
"cpp" => "text/x-c++",
"cs" => "text/x-csharp",
"rb" => "text/x-ruby",
"php" => "text/x-php",
"swift" => "text/x-swift",
"html" | "htm" => "text/html",
"css" => "text/css",
"exe" => "application/x-msdownload",
"msi" => "application/x-msi",
"ttf" => "font/ttf",
"otf" => "font/otf",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => "application/octet-stream",
}
}
pub fn extension_from_mime(mime: &str) -> String {
match mime.trim().to_lowercase().as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
"image/bmp" | "image/x-ms-bmp" => "bmp",
"image/x-icon" | "image/vnd.microsoft.icon" => "ico",
"image/tiff" => "tiff",
"image/x-adobe-dng" => "dng",
"image/x-canon-cr2" => "cr2",
"image/x-nikon-nef" => "nef",
"image/x-sony-arw" => "arw",
"audio/wav" | "audio/x-wav" | "audio/wave" => "wav",
"audio/mp3" | "audio/mpeg" => "mp3",
"audio/flac" => "flac",
"audio/ogg" => "ogg",
"audio/mp4" => "m4a",
"audio/aac" | "audio/x-aac" => "aac",
"audio/x-ms-wma" => "wma",
"audio/opus" => "opus",
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/quicktime" => "mov",
"video/x-msvideo" => "avi",
"video/x-matroska" => "mkv",
"video/x-flv" => "flv",
"video/x-ms-wmv" => "wmv",
"video/mpeg" => "mpg",
"video/3gpp" => "3gp",
"video/ogg" => "ogv",
"video/mp2t" => "ts",
"application/pdf" => "pdf",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
"application/vnd.ms-powerpoint" => "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
"application/vnd.oasis.opendocument.text" => "odt",
"application/vnd.oasis.opendocument.spreadsheet" => "ods",
"application/vnd.oasis.opendocument.presentation" => "odp",
"application/rtf" => "rtf",
"text/plain" => "txt",
"text/markdown" => "md",
"text/csv" => "csv",
"application/json" => "json",
"application/xml" | "text/xml" => "xml",
"application/x-yaml" | "text/yaml" => "yaml",
"application/toml" => "toml",
"application/sql" => "sql",
"application/zip" => "zip",
"application/x-rar-compressed" | "application/vnd.rar" => "rar",
"application/x-7z-compressed" => "7z",
"application/x-tar" => "tar",
"application/gzip" => "gz",
"application/x-bzip2" => "bz2",
"application/x-xz" => "xz",
"application/x-iso9660-image" => "iso",
"application/x-apple-diskimage" => "dmg",
"application/vnd.android.package-archive" => "apk",
"application/java-archive" => "jar",
"application/vnd.webxdc+zip" => "xdc",
"model/obj" => "obj",
"model/gltf+json" => "gltf",
"model/gltf-binary" => "glb",
"model/stl" | "application/sla" => "stl",
"model/vnd.collada+xml" => "dae",
"text/javascript" | "application/javascript" => "js",
"text/typescript" | "application/typescript" => "ts",
"text/x-python" | "application/x-python" => "py",
"text/x-rust" => "rs",
"text/x-go" => "go",
"text/x-java" => "java",
"text/x-c" => "c",
"text/x-c++" => "cpp",
"text/x-csharp" => "cs",
"text/x-ruby" => "rb",
"text/x-php" => "php",
"text/x-swift" => "swift",
"text/html" => "html",
"text/css" => "css",
"application/x-msdownload" | "application/x-dosexec" => "exe",
"application/x-msi" => "msi",
"application/x-font-ttf" | "font/ttf" => "ttf",
"application/x-font-otf" | "font/otf" => "otf",
"font/woff" => "woff",
"font/woff2" => "woff2",
_ => {
let lower = mime.trim().to_lowercase();
let sub = lower.split('/').nth(1).unwrap_or("");
let safe = !sub.is_empty() && sub.len() <= 16 && sub.bytes().all(|b| b.is_ascii_alphanumeric());
return if safe { sub.to_string() } else { "bin".to_string() };
}
}.to_string()
}
pub fn sanitize_filename(name: &str) -> String {
let base = name.rsplit('/').next().unwrap_or(name);
let base = base.rsplit('\\').next().unwrap_or(base);
let sanitized: String = base.chars().filter(|c| {
!matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0')
}).collect();
let sanitized = sanitized.trim_matches(|c: char| c == '.' || c == ' ');
if sanitized.is_empty() {
return String::new();
}
if let Some(dot_pos) = sanitized.rfind('.') {
let stem = &sanitized[..dot_pos];
let ext = &sanitized[dot_pos..];
if stem.len() > 64 {
let truncated = &stem[..stem.floor_char_boundary(64)];
return format!("{}{}", truncated, ext);
}
} else if sanitized.len() > 64 {
let truncated = &sanitized[..sanitized.floor_char_boundary(64)];
return truncated.to_string();
}
sanitized.to_string()
}
pub fn resolve_unique_filename(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
let candidate = dir.join(name);
if !candidate.exists() {
return candidate;
}
let stem = std::path::Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(name);
let ext = std::path::Path::new(name)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let mut counter = 1u32;
loop {
let suffixed = if ext.is_empty() {
format!("{}-{}", stem, counter)
} else {
format!("{}-{}.{}", stem, counter, ext)
};
let candidate = dir.join(&suffixed);
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
pub fn decrypt_and_save_attachment(
encrypted_data: &[u8],
key: &str,
nonce: &str,
name: &str,
extension: &str,
) -> Result<(std::path::PathBuf, String), String> {
decrypt_and_save_attachment_owned(encrypted_data.to_vec(), key, nonce, name, extension)
}
pub fn decrypt_and_save_attachment_owned(
encrypted_data: Vec<u8>,
key: &str,
nonce: &str,
name: &str,
extension: &str,
) -> Result<(std::path::PathBuf, String), String> {
let decrypted = if key.is_empty() || nonce.is_empty() {
encrypted_data
} else {
decrypt_data_owned(encrypted_data, key, nonce)?
};
let file_hash = sha256_hex(&decrypted);
let dir = crate::db::get_download_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
let target_name = if name.is_empty() {
format!("{}.{}", file_hash, extension)
} else {
name.to_string()
};
let candidate = dir.join(&target_name);
let already_exists = candidate.exists()
&& std::fs::metadata(&candidate).map(|m| m.len() == decrypted.len() as u64).unwrap_or(false)
&& std::fs::read(&candidate).map(|b| sha256_hex(&b) == file_hash).unwrap_or(false);
if already_exists {
return Ok((candidate, file_hash));
}
let file_path = resolve_unique_filename(&dir, &target_name);
let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, extension));
std::fs::write(&tmp_path, &decrypted).map_err(|e| format!("Failed to write file: {}", e))?;
std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
Ok((file_path, file_hash))
}
pub fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
if bytes < KB as u64 {
format!("{} B", bytes)
} else if bytes < MB as u64 {
format!("{:.1} KB", bytes as f64 / KB)
} else if bytes < GB as u64 {
format!("{:.1} MB", bytes as f64 / MB)
} else {
format!("{:.1} GB", bytes as f64 / GB)
}
}
pub fn is_image_mime(mime: &str) -> bool {
mime.trim().starts_with("image/")
}
pub fn mime_from_extension_safe(extension: &str, image_only: bool) -> Result<String, String> {
let mime = mime_from_extension(extension).to_string();
if image_only && !is_image_mime(&mime) {
return Err(mime);
}
Ok(mime)
}
pub const RECOGNISED_MIMES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/x-icon",
"image/svg+xml",
"image/bmp",
"video/webm",
"video/x-matroska",
"video/quicktime",
"video/mp4",
];
pub fn mime_from_magic_bytes(bytes: &[u8]) -> &'static str {
if bytes.len() < 4 {
return "application/octet-stream";
}
match bytes[0] {
0x89 if bytes[1..4] == [0x50, 0x4E, 0x47] => "image/png",
0xFF if bytes[1..3] == [0xD8, 0xFF] => "image/jpeg",
b'G' if bytes.len() >= 6 && (bytes[..6] == *b"GIF87a" || bytes[..6] == *b"GIF89a") => "image/gif",
b'R' if bytes.len() > 12 && bytes[..4] == *b"RIFF" && bytes[8..12] == *b"WEBP" => "image/webp",
0x49 if bytes[1..4] == [0x49, 0x2A, 0x00] => "image/tiff",
0x4D if bytes[1..4] == [0x4D, 0x00, 0x2A] => "image/tiff",
0x00 if bytes[1..4] == [0x00, 0x01, 0x00] => "image/x-icon",
b'<' if bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") => "image/svg+xml",
b'B' if bytes[1] == b'M' => "image/bmp",
0x1A if bytes[1..4] == [0x45, 0xDF, 0xA3] => {
if bytes.len() >= 64 && bytes[..64].windows(4).any(|w| w == b"webm") {
"video/webm"
} else {
"video/x-matroska"
}
}
_ if bytes.len() >= 12 && bytes[4..8] == *b"ftyp" => match &bytes[8..12] {
b"qt " => "video/quicktime",
_ => "video/mp4",
},
_ => "application/octet-stream",
}
}
#[inline]
pub fn looks_encrypted(s: &str) -> bool {
if s.len() < 56 { return false; }
is_all_lowercase_hex(s.as_bytes())
}
#[cfg(target_arch = "aarch64")]
#[inline]
fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
use std::arch::aarch64::*;
unsafe {
let mut i = 0;
while i + 16 <= bytes.len() {
let chunk = vld1q_u8(bytes.as_ptr().add(i));
let is_digit = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'0')),
vcleq_u8(chunk, vdupq_n_u8(b'9')));
let is_af = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'a')),
vcleq_u8(chunk, vdupq_n_u8(b'f')));
if vminvq_u8(vorrq_u8(is_digit, is_af)) == 0 { return false; }
i += 16;
}
while i < bytes.len() {
let b = bytes[i];
if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
i += 1;
}
}
true
}
#[cfg(target_arch = "x86_64")]
#[inline]
fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
use std::arch::x86_64::*;
unsafe {
let mut i = 0;
while i + 16 <= bytes.len() {
let chunk = _mm_loadu_si128(bytes.as_ptr().add(i) as *const __m128i);
let is_digit = _mm_cmpeq_epi8(
_mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'0' as i8)), _mm_set1_epi8(9)),
_mm_setzero_si128());
let is_af = _mm_cmpeq_epi8(
_mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'a' as i8)), _mm_set1_epi8(5)),
_mm_setzero_si128());
if _mm_movemask_epi8(_mm_or_si128(is_digit, is_af)) != 0xFFFF { return false; }
i += 16;
}
while i < bytes.len() {
let b = bytes[i];
if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
i += 1;
}
}
true
}
#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
#[inline]
fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
const IS_LOWER_HEX: [bool; 256] = {
let mut t = [false; 256];
t[b'0' as usize] = true; t[b'1' as usize] = true; t[b'2' as usize] = true;
t[b'3' as usize] = true; t[b'4' as usize] = true; t[b'5' as usize] = true;
t[b'6' as usize] = true; t[b'7' as usize] = true; t[b'8' as usize] = true;
t[b'9' as usize] = true; t[b'a' as usize] = true; t[b'b' as usize] = true;
t[b'c' as usize] = true; t[b'd' as usize] = true; t[b'e' as usize] = true;
t[b'f' as usize] = true;
t
};
bytes.iter().all(|&b| IS_LOWER_HEX[b as usize])
}
pub async fn maybe_encrypt_inner(mut input: String, password: Option<String>) -> String {
let mut key: [u8; 32] = match password {
None => crate::state::ENCRYPTION_KEY.get().expect("Encryption key must be set"),
Some(password) => hash_pass(&password).await,
};
let framed = seal_framed(input.as_bytes(), &key).expect("Encryption should not fail");
wipe_vec(unsafe { input.as_mut_vec() });
if !crate::state::ENCRYPTION_KEY.has_key() {
crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
}
key.zeroize();
crate::simd::hex::bytes_to_hex_string(&framed)
}
pub async fn maybe_decrypt_inner(ciphertext: String, password: Option<String>) -> Result<String, ()> {
let has_password = password.is_some();
let mut key: [u8; 32] = if let Some(pass) = password {
hash_pass(&pass).await
} else {
match crate::state::ENCRYPTION_KEY.get() {
Some(k) => k,
None => return Err(()),
}
};
let Some(plaintext) = open_hex(&ciphertext, &key) else {
key.zeroize();
return Err(());
};
if has_password && !crate::state::ENCRYPTION_KEY.has_key() {
crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
}
key.zeroize();
Ok(plaintext)
}
pub async fn maybe_encrypt(input: String) -> String {
if crate::state::is_encryption_enabled_fast() {
maybe_encrypt_inner(input, None).await
} else {
input
}
}
pub async fn maybe_decrypt(input: String) -> Result<String, ()> {
if !looks_encrypted(&input) {
return Ok(input);
}
if let Some(plaintext) = open_with_vault(&input) {
return Ok(plaintext);
}
if crate::state::is_encryption_enabled_fast() { Err(()) } else { Ok(input) }
}
fn open_with_vault(hex_data: &str) -> Option<String> {
let mut key = crate::state::ENCRYPTION_KEY.get()?;
let out = open_hex(hex_data, &key);
key.zeroize();
out
}
pub fn open_batch<R>(f: impl FnOnce(&mut AtRestOpener) -> R) -> R {
f(&mut AtRestOpener { cipher: None, loaded: false })
}
pub struct AtRestOpener {
cipher: Option<ChaCha20Poly1305>,
loaded: bool,
}
impl AtRestOpener {
#[allow(clippy::result_unit_err)] pub fn open(&mut self, input: String) -> Result<String, ()> {
if !looks_encrypted(&input) {
return Ok(input);
}
if !self.loaded {
self.loaded = true;
if let Some(mut key) = crate::state::ENCRYPTION_KEY.get() {
self.cipher = Some(ChaCha20Poly1305::new(&key));
key.zeroize();
}
}
if let Some(plaintext) = self.cipher.as_ref().and_then(|c| open_hex_with(c, &input)) {
return Ok(plaintext);
}
if crate::state::is_encryption_enabled_fast() { Err(()) } else { Ok(input) }
}
}
pub fn encrypt_blob_with_key(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
seal_framed(plaintext, key).map_err(|e| format!("blob encryption failed: {}", e))
}
pub fn decrypt_blob_with_key(stored: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
if stored.len() < NONCE_LEN + TAG_LEN {
return Err("blob too short to be ciphertext".to_string());
}
open_framed(stored.to_vec(), key).ok_or_else(|| "blob decryption failed (wrong key or corrupted)".to_string())
}
pub fn maybe_encrypt_blob(plaintext: &[u8]) -> Result<Vec<u8>, String> {
if !crate::state::is_encryption_enabled_fast() {
return Ok(plaintext.to_vec());
}
let mut key = crate::state::ENCRYPTION_KEY
.get()
.ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
let out = encrypt_blob_with_key(plaintext, &key);
key.zeroize();
out
}
pub fn maybe_decrypt_blob(stored: &[u8]) -> Vec<u8> {
if stored.len() == 32 {
return stored.to_vec();
}
match crate::state::ENCRYPTION_KEY.get() {
Some(mut key) => {
let out = decrypt_blob_with_key(stored, &key).unwrap_or_else(|_| stored.to_vec());
key.zeroize();
out
}
None => stored.to_vec(),
}
}
pub fn maybe_encrypt_text(plaintext: &str) -> Result<String, String> {
if !crate::state::is_encryption_enabled_fast() {
return Ok(plaintext.to_string());
}
let mut key = crate::state::ENCRYPTION_KEY
.get()
.ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
let out = encrypt_with_key(plaintext, &key);
key.zeroize();
out
}
pub fn maybe_decrypt_text(stored: &str) -> String {
if !looks_encrypted(stored) {
return stored.to_string();
}
match crate::state::ENCRYPTION_KEY.get() {
Some(mut key) => {
let out = decrypt_with_key(stored, &key).unwrap_or_else(|_| stored.to_string());
key.zeroize();
out
}
None => stored.to_string(),
}
}
#[cfg(test)]
mod at_rest_batch_tests {
use super::*;
fn with_vault<R>(key: Option<[u8; 32]>, enabled: bool, f: impl FnOnce() -> R) -> R {
let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
match key {
Some(k) => crate::state::ENCRYPTION_KEY.set(k, &[]),
None => crate::state::ENCRYPTION_KEY.clear(&[]),
}
crate::state::set_encryption_enabled(enabled);
let out = f();
crate::state::set_encryption_enabled(false);
crate::state::ENCRYPTION_KEY.clear(&[]);
out
}
fn rows(key: &[u8; 32]) -> Vec<String> {
vec![
encrypt_with_key("first", key).unwrap(),
"plain row".to_string(),
encrypt_with_key("", key).unwrap(),
encrypt_with_key(&"long ".repeat(300), key).unwrap(),
encrypt_with_key("wrong key", &[9u8; 32]).unwrap(),
"cafe".repeat(20),
]
}
#[test]
fn a_batch_answers_every_row_as_maybe_decrypt_does() {
use futures_util::FutureExt;
let key = [0x55u8; 32];
let rows = rows(&key);
for enabled in [true, false] {
with_vault(Some(key), enabled, || {
let expected: Vec<_> = rows.iter().map(|r| maybe_decrypt(r.clone()).now_or_never().unwrap()).collect();
let batched = open_batch(|at_rest| rows.iter().map(|r| at_rest.open(r.clone())).collect::<Vec<_>>());
assert_eq!(batched, expected, "encryption enabled: {enabled}");
});
}
}
#[test]
fn plaintext_rows_never_need_the_key() {
let got = with_vault(None, true, || open_batch(|at_rest| vec![at_rest.open("hello".into()), at_rest.open("a b c".into())]));
assert_eq!(got, vec![Ok("hello".to_string()), Ok("a b c".to_string())]);
}
#[test]
fn a_ciphertext_with_no_key_in_the_vault_is_refused_while_encryption_is_on() {
let sealed = encrypt_with_key("secret", &[0x55u8; 32]).unwrap();
let on = with_vault(None, true, || open_batch(|at_rest| at_rest.open(sealed.clone())));
assert_eq!(on, Err(()));
let off = with_vault(None, false, || open_batch(|at_rest| at_rest.open(sealed.clone())));
assert_eq!(off, Ok(sealed), "encryption off: an undecryptable row reads through as-is");
}
}
#[cfg(test)]
mod attachment_cipher_tests {
use super::*;
const PINNED: [(usize, &str); 6] = [
(0, "022f841c702b66b0bec8111f67de221d"),
(1, "292017643f9281af327f9487e3df3e3d7c"),
(15, "29a932a407ebe042f8dc56e1ba4ae84d9c12aa8198954368a48d9deebb7ab9"),
(16, "29a932a407ebe042f8dc56e1ba4ae8fc890ce0e3fa7321a07ba4f5bedc8e26e7"),
(17, "29a932a407ebe042f8dc56e1ba4ae8fcf1bed63597cf6053bc96b53c32185e0ecd"),
(100, "29a932a407ebe042f8dc56e1ba4ae8fcf13fc2f1f1a2cc0856a0d376defbf35bef7b179b6ad5b2a9f52b19922f7cf2bc4960c60abb73d0a56dabe7c406cb1555ca5427dad72a529c7f8f0c592ce2d0c49b46f9f9011bda808e760db92ad6ecc171a88a1ce072e4f68ec1b5cc3a5e23c37c645be7"),
];
#[test]
fn attachment_cipher_matches_pinned_vectors() {
let params = EncryptionParams {
key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".to_string(),
nonce: "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf".to_string(),
};
for (len, want) in PINNED {
let pt: Vec<u8> = (0..len).map(|i| (i * 7 + 3) as u8).collect();
let sealed = encrypt_data(&pt, ¶ms).unwrap();
assert_eq!(hex::encode(&sealed), want, "seal len={len}");
let ct = hex::decode(want).unwrap();
assert_eq!(decrypt_data(&ct, ¶ms.key, ¶ms.nonce).unwrap(), pt, "open len={len}");
}
}
}
#[cfg(test)]
mod at_rest_tests {
use super::*;
#[test]
fn blob_roundtrip_with_explicit_key() {
let key = [7u8; 32];
let secret = [0x42u8; 32];
let ct = encrypt_blob_with_key(&secret, &key).unwrap();
assert_eq!(ct.len(), 12 + 32 + 16, "nonce + ciphertext + tag");
assert_ne!(&ct[12..44], &secret[..], "ciphertext must not equal plaintext");
assert_eq!(decrypt_blob_with_key(&ct, &key).unwrap(), secret.to_vec());
}
#[test]
fn blob_wrong_key_fails() {
let ct = encrypt_blob_with_key(&[1u8; 32], &[7u8; 32]).unwrap();
assert!(decrypt_blob_with_key(&ct, &[9u8; 32]).is_err());
}
#[test]
fn encrypted_text_is_always_detected_as_encrypted() {
let key = [3u8; 32];
for s in ["", "[]", "{}", "a"] {
let ct = encrypt_with_key(s, &key).unwrap();
assert!(looks_encrypted(&ct), "ciphertext for {:?} must look encrypted", s);
assert_eq!(decrypt_with_key(&ct, &key).unwrap(), s);
}
}
}
pub use crate::simd::image::nearest_neighbor_downsample_rgba;
pub fn generate_thumbhash_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<String> {
use fast_thumbhash::{rgba_to_thumb_hash, base91_encode};
const MAX_DIM: u32 = 100;
let (thumb_w, thumb_h) = if width <= MAX_DIM && height <= MAX_DIM {
(width, height)
} else if width > height {
(MAX_DIM, (MAX_DIM * height / width).max(1))
} else {
((MAX_DIM * width / height).max(1), MAX_DIM)
};
let thumbnail = if thumb_w == width && thumb_h == height {
pixels.to_vec()
} else {
nearest_neighbor_downsample_rgba(pixels, width, height, thumb_w, thumb_h)
};
let hash = rgba_to_thumb_hash(thumb_w as usize, thumb_h as usize, &thumbnail);
Some(base91_encode(&hash))
}
pub fn decode_image_bounded(bytes: &[u8]) -> Result<image::DynamicImage, String> {
use image::ImageDecoder;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| format!("image format: {e}"))?;
reader.limits(bounded_image_limits());
let mut decoder = reader.into_decoder().map_err(|e| format!("image decode: {e}"))?;
let orientation = decoder.orientation().map_err(|e| format!("image orientation: {e}"))?;
let mut img = image::DynamicImage::from_decoder(decoder)
.map_err(|e| format!("image decode: {e}"))?;
img.apply_orientation(orientation);
Ok(img)
}
pub fn bounded_image_limits() -> image::Limits {
let mut limits = image::Limits::default();
limits.max_image_width = Some(16_384);
limits.max_image_height = Some(16_384);
limits.max_alloc = Some(256 * 1024 * 1024);
limits
}
pub fn generate_image_metadata(file_bytes: &[u8]) -> Option<crate::types::ImageMetadata> {
let img = decode_image_bounded(file_bytes).ok()?;
let width = img.width();
let height = img.height();
let small = if width > 100 || height > 100 { img.thumbnail(100, 100) } else { img };
let rgba = small.to_rgba8();
let thumbhash = generate_thumbhash_from_rgba(rgba.as_raw(), rgba.width(), rgba.height())?;
Some(crate::types::ImageMetadata {
thumbhash,
width,
height,
})
}
#[cfg(test)]
mod tests {
#[test]
fn a_senders_mime_can_only_name_a_plain_extension() {
use super::extension_from_mime;
assert_eq!(extension_from_mime("image/png"), "png");
assert_eq!(extension_from_mime("application/x-bittorrent"), "bin");
assert_eq!(extension_from_mime("audio/opus"), "opus");
assert_eq!(extension_from_mime("x/.\\..\\..\\AppData\\Startup\\a.bat"), "bin");
assert_eq!(extension_from_mime("x/../../etc"), "bin");
assert_eq!(extension_from_mime("nothing"), "bin");
}
use super::*;
#[test]
fn magic_bytes_name_video_and_bitmaps() {
let mut mp4 = vec![0u8, 0, 0, 0x20];
mp4.extend_from_slice(b"ftypisom");
assert_eq!(mime_from_magic_bytes(&mp4), "video/mp4");
let mut mov = vec![0u8, 0, 0, 0x14];
mov.extend_from_slice(b"ftypqt ");
assert_eq!(mime_from_magic_bytes(&mov), "video/quicktime");
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
webm.extend_from_slice(&[0u8; 20]);
webm.extend_from_slice(b"webm");
webm.extend_from_slice(&[0u8; 40]);
assert_eq!(mime_from_magic_bytes(&webm), "video/webm");
let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
mkv.extend_from_slice(&[0u8; 64]);
assert_eq!(mime_from_magic_bytes(&mkv), "video/x-matroska");
let mut bmp = b"BM".to_vec();
bmp.extend_from_slice(&[0u8; 20]);
assert_eq!(mime_from_magic_bytes(&bmp), "image/bmp");
}
#[test]
fn every_recognised_mime_is_reachable_from_some_bytes() {
let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
webm.extend_from_slice(&[0u8; 20]);
webm.extend_from_slice(b"webm");
webm.resize(80, 0);
let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
mkv.resize(80, 0);
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0u8; 4]);
webp.extend_from_slice(b"WEBP____");
let samples: Vec<Vec<u8>> = vec![
vec![0x89, b'P', b'N', b'G'],
vec![0xFF, 0xD8, 0xFF, 0xE0],
b"GIF89a__".to_vec(),
webp,
vec![0x49, 0x49, 0x2A, 0x00],
vec![0x00, 0x00, 0x01, 0x00],
b"<svg xmlns".to_vec(),
b"BM______".to_vec(),
webm,
mkv,
b"\0\0\0\x14ftypqt ".to_vec(),
b"\0\0\0\x20ftypisom".to_vec(),
];
let reachable: std::collections::HashSet<&str> =
samples.iter().map(|b| mime_from_magic_bytes(b)).collect();
for m in RECOGNISED_MIMES {
assert!(reachable.contains(m), "{m} is listed but no bytes produce it");
}
assert_eq!(reachable.len(), RECOGNISED_MIMES.len(), "and nothing is produced that is not listed");
}
#[test]
fn magic_bytes_still_name_the_image_types() {
assert_eq!(mime_from_magic_bytes(&[0x89, b'P', b'N', b'G']), "image/png");
assert_eq!(mime_from_magic_bytes(&[0xFF, 0xD8, 0xFF, 0xE0]), "image/jpeg");
assert_eq!(mime_from_magic_bytes(b"GIF89a__"), "image/gif");
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0u8; 4]);
webp.extend_from_slice(b"WEBP____");
assert_eq!(mime_from_magic_bytes(&webp), "image/webp");
assert_eq!(mime_from_magic_bytes(b"<svg xmlns"), "image/svg+xml");
assert_eq!(mime_from_magic_bytes(&[0xDE, 0xAD, 0xBE, 0xEF]), "application/octet-stream");
assert_eq!(mime_from_magic_bytes(&[]), "application/octet-stream", "a short read is not a type");
assert_eq!(mime_from_magic_bytes(b"Bm"), "application/octet-stream", "and BMP is case-sensitive");
}
fn test_key() -> [u8; 32] {
[0x42u8; 32]
}
fn alt_key() -> [u8; 32] {
[0x99u8; 32]
}
#[test]
fn encrypt_decrypt_roundtrip_simple() {
let key = test_key();
let plaintext = "hello world";
let encrypted = encrypt_with_key(plaintext, &key).expect("encryption should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decryption should succeed");
assert_eq!(decrypted, plaintext, "roundtrip should preserve plaintext");
}
#[test]
fn encrypt_decrypt_100_random_strings() {
use rand::Rng;
let key = test_key();
let mut rng = rand::thread_rng();
for i in 0..100 {
let len = rng.gen_range(1..=200);
let s: String = (0..len).map(|_| rng.gen_range(0x20u8..0x7f) as char).collect();
let enc = encrypt_with_key(&s, &key)
.unwrap_or_else(|e| panic!("encryption failed on iteration {}: {}", i, e));
let dec = decrypt_with_key(&enc, &key)
.unwrap_or_else(|e| panic!("decryption failed on iteration {}: {}", i, e));
assert_eq!(dec, s, "roundtrip failed on iteration {}", i);
}
}
#[test]
fn encrypt_decrypt_empty_string() {
let key = test_key();
let encrypted = encrypt_with_key("", &key).expect("encrypting empty string should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting empty string should succeed");
assert_eq!(decrypted, "", "empty string roundtrip should produce empty string");
}
#[test]
fn encrypt_decrypt_large_string() {
let key = test_key();
let plaintext = "A".repeat(10 * 1024); let encrypted = encrypt_with_key(&plaintext, &key).expect("encrypting 10KB should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting 10KB should succeed");
assert_eq!(decrypted, plaintext, "10KB roundtrip should preserve content");
}
#[test]
fn decrypt_with_wrong_key_fails() {
let key = test_key();
let wrong_key = alt_key();
let encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
let result = decrypt_with_key(&encrypted, &wrong_key);
assert!(result.is_err(), "decryption with wrong key should fail");
}
#[test]
fn decrypt_corrupted_ciphertext_fails() {
let key = test_key();
let mut encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
let bytes: Vec<u8> = encrypted.bytes().collect();
if bytes.len() > 30 {
let mut chars: Vec<char> = encrypted.chars().collect();
chars[30] = if chars[30] == '0' { 'f' } else { '0' };
encrypted = chars.into_iter().collect();
}
let result = decrypt_with_key(&encrypted, &key);
assert!(result.is_err(), "decryption of corrupted ciphertext should fail");
}
#[test]
fn different_keys_produce_different_ciphertext() {
let key1 = test_key();
let key2 = alt_key();
let plaintext = "same plaintext";
let enc1 = encrypt_with_key(plaintext, &key1).expect("enc1 should succeed");
let enc2 = encrypt_with_key(plaintext, &key2).expect("enc2 should succeed");
assert_ne!(enc1, enc2, "different keys should produce different ciphertext");
}
#[test]
fn nonce_is_always_different() {
let key = test_key();
let plaintext = "same string encrypted twice";
let enc1 = encrypt_with_key(plaintext, &key).expect("enc1 should succeed");
let enc2 = encrypt_with_key(plaintext, &key).expect("enc2 should succeed");
let nonce1 = &enc1[..24];
let nonce2 = &enc2[..24];
assert_ne!(nonce1, nonce2, "nonces should differ between encryptions of the same plaintext");
}
#[test]
fn unicode_content_preserved() {
let key = test_key();
let plaintext = "Hello \u{1F600} \u{1F4A9} \u{1F30D} \u{00E9}\u{00E0}\u{00FC} \u{4E16}\u{754C} \u{0410}\u{0411}\u{0412}";
let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting unicode should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting unicode should succeed");
assert_eq!(decrypted, plaintext, "unicode content should be preserved through encrypt/decrypt");
}
#[test]
fn decrypt_too_short_ciphertext_fails() {
let key = test_key();
let result = decrypt_with_key("abcdef", &key);
assert!(result.is_err(), "ciphertext shorter than 24 hex chars should fail");
assert!(result.unwrap_err().contains("too short"), "error should mention too short");
}
#[test]
fn encrypt_output_is_hex_encoded() {
let key = test_key();
let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
assert!(encrypted.chars().all(|c| c.is_ascii_hexdigit()),
"encrypted output should be entirely hex characters");
}
#[test]
fn encrypt_output_has_correct_structure() {
let key = test_key();
let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
assert!(encrypted.len() > 24,
"encrypted output should have nonce (24 hex chars) plus ciphertext");
assert_eq!(encrypted.len() % 2, 0,
"encrypted output length should be even (hex pairs)");
}
#[test]
fn encrypt_decrypt_special_characters() {
let key = test_key();
let plaintext = r#"!@#$%^&*()_+-=[]{}|;':",.<>?/\`~"#;
let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting special chars should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting special chars should succeed");
assert_eq!(decrypted, plaintext, "special characters should survive roundtrip");
}
#[test]
fn encrypt_decrypt_newlines_and_whitespace() {
let key = test_key();
let plaintext = "line1\nline2\r\nline3\ttab\0null";
let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting whitespace should succeed");
let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting whitespace should succeed");
assert_eq!(decrypted, plaintext, "whitespace and control chars should survive roundtrip");
}
#[test]
fn decrypt_invalid_hex_in_nonce_fails() {
let key = test_key();
let bad = "zzzzzzzzzzzzzzzzzzzzzzzz0000000000000000";
let result = decrypt_with_key(bad, &key);
assert!(result.is_err(), "invalid hex in nonce should fail");
}
#[test]
fn decrypt_invalid_hex_in_ciphertext_fails() {
let key = test_key();
let bad = "000000000000000000000000gggggggg";
let result = decrypt_with_key(bad, &key);
assert!(result.is_err(), "invalid hex in ciphertext should fail");
}
#[test]
fn hex_encode_decode_roundtrip() {
let data = vec![0x00, 0x01, 0xff, 0x80, 0x7f];
let encoded = hex::encode(&data);
let decoded = hex::decode(&encoded).expect("decode should succeed");
assert_eq!(decoded, data, "hex encode/decode roundtrip should preserve bytes");
}
#[test]
fn hex_decode_odd_length_error() {
let result = hex::decode("abc");
assert!(result.is_err(), "odd-length hex string should fail");
assert!(result.unwrap_err().contains("Odd-length"), "error should mention odd-length");
}
#[test]
fn hex_decode_invalid_hex_error() {
let result = hex::decode("zzzz");
assert!(result.is_err(), "invalid hex characters should fail");
}
#[test]
fn hex_encode_empty() {
assert_eq!(hex::encode(&[]), "", "encoding empty bytes should produce empty string");
}
#[test]
fn hex_decode_empty() {
let decoded = hex::decode("").expect("decoding empty string should succeed");
assert!(decoded.is_empty(), "decoding empty string should produce empty vec");
}
#[tokio::test]
async fn hash_pass_deterministic() {
let key1 = hash_pass("my_password").await;
let key2 = hash_pass("my_password").await;
assert_eq!(key1, key2, "same password should always produce the same key");
}
#[tokio::test]
async fn hash_pass_different_passwords_different_keys() {
let key1 = hash_pass("password_one").await;
let key2 = hash_pass("password_two").await;
assert_ne!(key1, key2, "different passwords should produce different keys");
}
#[tokio::test]
async fn hash_pass_output_is_32_bytes() {
let key = hash_pass("test_password").await;
assert_eq!(key.len(), 32, "hash_pass should produce exactly 32 bytes");
assert!(key.iter().any(|&b| b != 0), "hash output should not be all zeros");
}
#[test]
fn generate_thumbhash_from_bytes_basic() {
let pixels: Vec<u8> = vec![
255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255,
];
let result = super::generate_thumbhash_from_rgba(&pixels, 2, 2);
assert!(result.is_some());
assert!(!result.unwrap().is_empty());
}
#[test]
fn generate_image_metadata_from_bytes() {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 255, 255]));
let mut buf = Vec::new();
let mut cursor = std::io::Cursor::new(&mut buf);
img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
let meta = super::generate_image_metadata(&buf);
assert!(meta.is_some());
let meta = meta.unwrap();
assert_eq!(meta.width, 4);
assert_eq!(meta.height, 4);
assert!(!meta.thumbhash.is_empty());
}
#[test]
fn generate_image_metadata_non_image() {
let text_bytes = b"this is not an image";
let meta = super::generate_image_metadata(text_bytes);
assert!(meta.is_none());
}
}