Skip to main content

vector_core/crypto/
mod.rs

1pub mod chachapoly;
2pub mod gcm;
3pub mod guarded_key;
4pub mod sha256;
5pub mod stream;
6pub use chachapoly::{wipe, wipe_vec};
7pub use guarded_key::GuardedKey;
8
9mod signer;
10pub use signer::GuardedSigner;
11
12use argon2::Argon2;
13use chachapoly::{ChaCha20Poly1305, NONCE_LEN, TAG_LEN};
14use zeroize::Zeroize;
15
16/// Derive a 32-byte key from a password using Argon2id.
17/// Parameters: 150MB memory, 10 iterations (matches src-tauri).
18pub async fn hash_pass(password: &str) -> [u8; 32] {
19    let password = password.to_string();
20    crate::rt::spawn_blocking(move || {
21        let salt = b"vectorvectovectvecvev";
22        let mut output = [0u8; 32];
23
24        let params = argon2::Params::new(
25            150_000, // 150 MB
26            10,      // iterations
27            1,       // parallelism
28            Some(32),
29        ).unwrap();
30
31        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
32        argon2.hash_password_into(password.as_bytes(), salt, &mut output).unwrap();
33
34        output
35    }).await.unwrap()
36}
37
38/// Seal `plaintext` as `nonce(12) || ciphertext || tag(16)` under a fresh nonce.
39fn seal_framed(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, chachapoly::Error> {
40    use rand::Rng;
41    let nonce: [u8; NONCE_LEN] = rand::thread_rng().gen();
42    let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + TAG_LEN);
43    out.extend_from_slice(&nonce);
44    out.extend_from_slice(plaintext);
45    let tag = ChaCha20Poly1305::new(key).seal_in_place(&nonce, &[], &mut out[NONCE_LEN..])?;
46    out.extend_from_slice(&tag);
47    Ok(out)
48}
49
50/// Open `nonce || ciphertext || tag`, leaving the plaintext at the front of the same allocation.
51fn open_framed(framed: Vec<u8>, key: &[u8; 32]) -> Option<Vec<u8>> {
52    open_framed_with(&ChaCha20Poly1305::new(key), framed)
53}
54
55fn open_framed_with(cipher: &ChaCha20Poly1305, mut framed: Vec<u8>) -> Option<Vec<u8>> {
56    let len = cipher.open_framed_in_place(&mut framed).ok()?.len();
57    framed.copy_within(NONCE_LEN..NONCE_LEN + len, 0);
58    framed.truncate(len);
59    Some(framed)
60}
61
62/// Open a hex at-rest string into its plaintext, decrypting inside the decode buffer.
63fn open_hex(hex_data: &str, key: &[u8; 32]) -> Option<String> {
64    open_hex_with(&ChaCha20Poly1305::new(key), hex_data)
65}
66
67fn open_hex_with(cipher: &ChaCha20Poly1305, hex_data: &str) -> Option<String> {
68    let plaintext = open_framed_with(cipher, crate::simd::hex::hex_string_to_bytes(hex_data))?;
69    // SAFETY: at-rest text is UTF-8 when sealed, and Poly1305 authenticates it unchanged.
70    Some(unsafe { String::from_utf8_unchecked(plaintext) })
71}
72
73/// Encrypt a string with an explicit key (ChaCha20-Poly1305), hex `nonce || ct || tag`.
74pub fn encrypt_with_key(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
75    let framed = seal_framed(plaintext.as_bytes(), key).map_err(|e| format!("Encryption failed: {}", e))?;
76    Ok(crate::simd::hex::bytes_to_hex_string(&framed))
77}
78
79/// Decrypt a hex-encoded ChaCha20-Poly1305 ciphertext with an explicit key.
80pub fn decrypt_with_key(hex_data: &str, key: &[u8; 32]) -> Result<String, String> {
81    if hex_data.len() < 2 * NONCE_LEN {
82        return Err("Ciphertext too short".to_string());
83    }
84    open_hex(hex_data, key).ok_or_else(|| "Decryption failed (wrong key or corrupted data)".to_string())
85}
86
87/// Check if encryption is enabled in the database.
88///
89/// Delegates to `state::resolve_encryption_enabled_from_db` — the single
90/// source of truth that handles the missing-row case consistently. The
91/// previous implementation defaulted to `false` on missing rows, which
92/// silently disagreed with `init_encryption_enabled` (defaulted to `true`)
93/// and silently mis-routed login flows after an account swap.
94pub fn is_encryption_enabled() -> bool {
95    crate::state::resolve_encryption_enabled_from_db()
96}
97
98/// Simple hex encode/decode (for crypto module internal use).
99mod hex {
100    pub fn encode(bytes: &[u8]) -> String {
101        // SIMD hex encode (NEON/SSE2, fast-pathing 32/16-byte). Identical lowercase output to the
102        // old per-byte format! loop, but it runs on every encrypt (ciphertext + nonce).
103        crate::simd::hex::bytes_to_hex_string(bytes)
104    }
105
106    pub fn decode(hex: &str) -> Result<Vec<u8>, String> {
107        if !hex.len().is_multiple_of(2) {
108            return Err("Odd-length hex string".to_string());
109        }
110        // SIMD-validated decode (NEON/SSE2 in-register hex validation). Runs on every message read
111        // (decrypt), so the recurring path stays fast; rejects non-hex like the old scalar loop.
112        crate::simd::hex::hex_string_to_bytes_checked(hex)
113            .ok_or_else(|| "Invalid hex character".to_string())
114    }
115}
116
117// ============================================================================
118// AES-256-GCM File Encryption (for NIP-96/Blossom attachments)
119// ============================================================================
120
121/// Parameters for AES-256-GCM file encryption (hex-encoded key + nonce).
122#[derive(Debug)]
123pub struct EncryptionParams {
124    pub key: String,   // 32-byte key as hex
125    pub nonce: String, // 16-byte nonce as hex (0xChat-compatible)
126}
127
128/// Generate random AES-256-GCM encryption parameters.
129pub fn generate_encryption_params() -> EncryptionParams {
130    use rand::Rng;
131    let mut rng = rand::thread_rng();
132    let mut key: [u8; 32] = rng.gen();
133    let nonce: [u8; 16] = rng.gen();
134    let params = EncryptionParams {
135        key: hex::encode(&key),
136        nonce: hex::encode(&nonce),
137    };
138    key.iter_mut().for_each(|b| *b = 0); // zeroize
139    params
140}
141
142/// Parse an attachment key and 16-byte nonce from hex. `key_error` names a key of the
143/// wrong length, as encryption and decryption have always reported it.
144pub(crate) fn attachment_params(key_hex: &str, nonce_hex: &str, key_error: &str) -> Result<([u8; 32], [u8; 16]), String> {
145    let mut key_bytes = hex::decode(key_hex).map_err(|e| format!("Invalid key: {}", e))?;
146    let nonce_bytes = hex::decode(nonce_hex).map_err(|e| format!("Invalid nonce: {}", e))?;
147    let key: Result<[u8; 32], _> = key_bytes.as_slice().try_into();
148    key_bytes.zeroize();
149    let key = key.map_err(|_| key_error.to_string())?;
150    let nonce: [u8; 16] = nonce_bytes.try_into().map_err(|_| "Invalid nonce length".to_string())?;
151    Ok((key, nonce))
152}
153
154/// Encrypt data with AES-256-GCM using a 16-byte nonce (0xChat-compatible).
155pub fn encrypt_data(data: &[u8], params: &EncryptionParams) -> Result<Vec<u8>, String> {
156    let (mut key, nonce) = attachment_params(&params.key, &params.nonce, "Invalid encryption key")?;
157    // Room for the tag up front: appending it to an exact-size copy would reallocate
158    // and move the whole ciphertext a second time.
159    let mut buffer = Vec::with_capacity(data.len() + gcm::TAG_LEN);
160    buffer.extend_from_slice(data);
161    let tag = gcm::seal(&key, &nonce, &mut buffer);
162    key.zeroize();
163    buffer.extend_from_slice(&tag);
164    Ok(buffer)
165}
166
167/// Decrypt data with AES-256-GCM using a 16-byte nonce (0xChat-compatible).
168/// Input format: ciphertext || 16-byte auth tag.
169pub fn decrypt_data(encrypted_data: &[u8], key_hex: &str, nonce_hex: &str) -> Result<Vec<u8>, String> {
170    decrypt_data_owned(encrypted_data.to_vec(), key_hex, nonce_hex)
171}
172
173/// [`decrypt_data`] in place, for a caller that owns the ciphertext: the plaintext
174/// comes back in the same allocation, so nothing the size of the file is copied.
175pub fn decrypt_data_owned(mut data: Vec<u8>, key_hex: &str, nonce_hex: &str) -> Result<Vec<u8>, String> {
176    if data.len() < gcm::TAG_LEN {
177        return Err(format!("Invalid Input: encrypted data too small ({} bytes, minimum 16 bytes required for authentication tag)", data.len()));
178    }
179    let (mut key, nonce) = attachment_params(key_hex, nonce_hex, "Invalid decryption key")?;
180    let body = data.len() - gcm::TAG_LEN;
181    let tag: [u8; gcm::TAG_LEN] = data[body..].try_into().unwrap();
182    data.truncate(body);
183    let opened = gcm::open(&key, &nonce, &mut data, &tag);
184    key.zeroize();
185    opened.map_err(|_| "aead::Error".to_string())?;
186    Ok(data)
187}
188
189/// Calculate SHA-256 hash of data, returned as hex string.
190pub fn sha256_hex(data: &[u8]) -> String {
191    hex::encode(&sha256::digest(data))
192}
193
194/// Identity basis for a RECEIVED attachment: the sender's `ox` (plaintext
195/// hash) when provided — it's what enables honest cross-message dedup — else
196/// a digest of nonce+url. Never the raw nonce: senders can and do reuse
197/// nonces, which cross-bound DIFFERENT files to one identity (the "new image
198/// renders as an old one" class). The upload URL is unique per ciphertext,
199/// so the digest is collision-resistant even against nonce reuse. Nothing
200/// honest ever writes a file under the digest name, so existence of one is
201/// never proof of download — reuse decisions stay with the content-verified
202/// download path.
203pub fn attachment_identity_basis(ox: Option<&str>, nonce: &str, url: &str) -> String {
204    match ox.filter(|h| !h.is_empty()) {
205        Some(h) => h.to_string(),
206        None => {
207            let mut buf = Vec::with_capacity(nonce.len() + 1 + url.len());
208            buf.extend_from_slice(nonce.as_bytes());
209            buf.push(0);
210            buf.extend_from_slice(url.as_bytes());
211            sha256_hex(&buf)
212        }
213    }
214}
215
216/// Get MIME type from file extension.
217pub fn mime_from_extension(ext: &str) -> &'static str {
218    match ext.to_lowercase().as_str() {
219        "png" => "image/png",
220        "jpg" | "jpeg" => "image/jpeg",
221        "gif" => "image/gif",
222        "webp" => "image/webp",
223        "svg" => "image/svg+xml",
224        "bmp" => "image/bmp",
225        "ico" => "image/x-icon",
226        "tiff" | "tif" => "image/tiff",
227        "dng" => "image/x-adobe-dng",
228        "cr2" => "image/x-canon-cr2",
229        "nef" => "image/x-nikon-nef",
230        "arw" => "image/x-sony-arw",
231        "mp4" => "video/mp4",
232        "webm" => "video/webm",
233        "mov" => "video/quicktime",
234        "avi" => "video/x-msvideo",
235        "mkv" => "video/x-matroska",
236        "flv" => "video/x-flv",
237        "wmv" => "video/x-ms-wmv",
238        "mpg" | "mpeg" => "video/mpeg",
239        "3gp" => "video/3gpp",
240        "ogv" => "video/ogg",
241        "ts" => "video/mp2t",
242        "mp3" => "audio/mpeg",
243        "ogg" => "audio/ogg",
244        "wav" => "audio/wav",
245        "flac" => "audio/flac",
246        "m4a" => "audio/mp4",
247        "aac" => "audio/aac",
248        "wma" => "audio/x-ms-wma",
249        "opus" => "audio/opus",
250        "pdf" => "application/pdf",
251        "doc" => "application/msword",
252        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
253        "xls" => "application/vnd.ms-excel",
254        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
255        "ppt" => "application/vnd.ms-powerpoint",
256        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
257        "odt" => "application/vnd.oasis.opendocument.text",
258        "ods" => "application/vnd.oasis.opendocument.spreadsheet",
259        "odp" => "application/vnd.oasis.opendocument.presentation",
260        "rtf" => "application/rtf",
261        "txt" => "text/plain",
262        "md" => "text/markdown",
263        "csv" => "text/csv",
264        "json" => "application/json",
265        "xml" => "application/xml",
266        "yaml" | "yml" => "application/x-yaml",
267        "toml" => "application/toml",
268        "sql" => "application/sql",
269        "zip" => "application/zip",
270        "rar" => "application/vnd.rar",
271        "7z" => "application/x-7z-compressed",
272        "tar" => "application/x-tar",
273        "gz" => "application/gzip",
274        "bz2" => "application/x-bzip2",
275        "xz" => "application/x-xz",
276        "iso" => "application/x-iso9660-image",
277        "dmg" => "application/x-apple-diskimage",
278        "apk" => "application/vnd.android.package-archive",
279        "jar" => "application/java-archive",
280        "xdc" => "application/vnd.webxdc+zip",
281        "obj" => "model/obj",
282        "gltf" => "model/gltf+json",
283        "glb" => "model/gltf-binary",
284        "stl" => "model/stl",
285        "dae" => "model/vnd.collada+xml",
286        "js" => "text/javascript",
287        "py" => "text/x-python",
288        "rs" => "text/x-rust",
289        "go" => "text/x-go",
290        "java" => "text/x-java",
291        "c" => "text/x-c",
292        "cpp" => "text/x-c++",
293        "cs" => "text/x-csharp",
294        "rb" => "text/x-ruby",
295        "php" => "text/x-php",
296        "swift" => "text/x-swift",
297        "html" | "htm" => "text/html",
298        "css" => "text/css",
299        "exe" => "application/x-msdownload",
300        "msi" => "application/x-msi",
301        "ttf" => "font/ttf",
302        "otf" => "font/otf",
303        "woff" => "font/woff",
304        "woff2" => "font/woff2",
305        _ => "application/octet-stream",
306    }
307}
308
309/// Convert a MIME type to a file extension.
310/// Falls back to using the MIME subtype when unknown.
311pub fn extension_from_mime(mime: &str) -> String {
312    match mime.trim().to_lowercase().as_str() {
313        // Images
314        "image/png" => "png",
315        "image/jpeg" | "image/jpg" => "jpg",
316        "image/gif" => "gif",
317        "image/webp" => "webp",
318        "image/svg+xml" => "svg",
319        "image/bmp" | "image/x-ms-bmp" => "bmp",
320        "image/x-icon" | "image/vnd.microsoft.icon" => "ico",
321        "image/tiff" => "tiff",
322        "image/x-adobe-dng" => "dng",
323        "image/x-canon-cr2" => "cr2",
324        "image/x-nikon-nef" => "nef",
325        "image/x-sony-arw" => "arw",
326        // Audio
327        "audio/wav" | "audio/x-wav" | "audio/wave" => "wav",
328        "audio/mp3" | "audio/mpeg" => "mp3",
329        "audio/flac" => "flac",
330        "audio/ogg" => "ogg",
331        "audio/mp4" => "m4a",
332        "audio/aac" | "audio/x-aac" => "aac",
333        "audio/x-ms-wma" => "wma",
334        "audio/opus" => "opus",
335        // Video
336        "video/mp4" => "mp4",
337        "video/webm" => "webm",
338        "video/quicktime" => "mov",
339        "video/x-msvideo" => "avi",
340        "video/x-matroska" => "mkv",
341        "video/x-flv" => "flv",
342        "video/x-ms-wmv" => "wmv",
343        "video/mpeg" => "mpg",
344        "video/3gpp" => "3gp",
345        "video/ogg" => "ogv",
346        "video/mp2t" => "ts",
347        // Documents
348        "application/pdf" => "pdf",
349        "application/msword" => "doc",
350        "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
351        "application/vnd.ms-excel" => "xls",
352        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
353        "application/vnd.ms-powerpoint" => "ppt",
354        "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
355        "application/vnd.oasis.opendocument.text" => "odt",
356        "application/vnd.oasis.opendocument.spreadsheet" => "ods",
357        "application/vnd.oasis.opendocument.presentation" => "odp",
358        "application/rtf" => "rtf",
359        // Text/Data
360        "text/plain" => "txt",
361        "text/markdown" => "md",
362        "text/csv" => "csv",
363        "application/json" => "json",
364        "application/xml" | "text/xml" => "xml",
365        "application/x-yaml" | "text/yaml" => "yaml",
366        "application/toml" => "toml",
367        "application/sql" => "sql",
368        // Archives
369        "application/zip" => "zip",
370        "application/x-rar-compressed" | "application/vnd.rar" => "rar",
371        "application/x-7z-compressed" => "7z",
372        "application/x-tar" => "tar",
373        "application/gzip" => "gz",
374        "application/x-bzip2" => "bz2",
375        "application/x-xz" => "xz",
376        "application/x-iso9660-image" => "iso",
377        "application/x-apple-diskimage" => "dmg",
378        "application/vnd.android.package-archive" => "apk",
379        "application/java-archive" => "jar",
380        "application/vnd.webxdc+zip" => "xdc",
381        // 3D
382        "model/obj" => "obj",
383        "model/gltf+json" => "gltf",
384        "model/gltf-binary" => "glb",
385        "model/stl" | "application/sla" => "stl",
386        "model/vnd.collada+xml" => "dae",
387        // Code
388        "text/javascript" | "application/javascript" => "js",
389        "text/typescript" | "application/typescript" => "ts",
390        "text/x-python" | "application/x-python" => "py",
391        "text/x-rust" => "rs",
392        "text/x-go" => "go",
393        "text/x-java" => "java",
394        "text/x-c" => "c",
395        "text/x-c++" => "cpp",
396        "text/x-csharp" => "cs",
397        "text/x-ruby" => "rb",
398        "text/x-php" => "php",
399        "text/x-swift" => "swift",
400        // Web
401        "text/html" => "html",
402        "text/css" => "css",
403        // Other
404        "application/x-msdownload" | "application/x-dosexec" => "exe",
405        "application/x-msi" => "msi",
406        "application/x-font-ttf" | "font/ttf" => "ttf",
407        "application/x-font-otf" | "font/otf" => "otf",
408        "font/woff" => "woff",
409        "font/woff2" => "woff2",
410        // Fallback: the subtype, when it can be a file extension. The MIME string is the
411        // sender's, and a Windows path separator in it would climb out of the download folder.
412        _ => {
413            let lower = mime.trim().to_lowercase();
414            let sub = lower.split('/').nth(1).unwrap_or("");
415            let safe = !sub.is_empty() && sub.len() <= 16 && sub.bytes().all(|b| b.is_ascii_alphanumeric());
416            return if safe { sub.to_string() } else { "bin".to_string() };
417        }
418    }.to_string()
419}
420
421/// Sanitize a filename for safe filesystem use.
422/// Strips path traversal, dangerous characters, and truncates to 64-char stem.
423pub fn sanitize_filename(name: &str) -> String {
424    let base = name.rsplit('/').next().unwrap_or(name);
425    let base = base.rsplit('\\').next().unwrap_or(base);
426
427    let sanitized: String = base.chars().filter(|c| {
428        !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0')
429    }).collect();
430
431    let sanitized = sanitized.trim_matches(|c: char| c == '.' || c == ' ');
432
433    if sanitized.is_empty() {
434        return String::new();
435    }
436
437    if let Some(dot_pos) = sanitized.rfind('.') {
438        let stem = &sanitized[..dot_pos];
439        let ext = &sanitized[dot_pos..];
440        if stem.len() > 64 {
441            let truncated = &stem[..stem.floor_char_boundary(64)];
442            return format!("{}{}", truncated, ext);
443        }
444    } else if sanitized.len() > 64 {
445        let truncated = &sanitized[..sanitized.floor_char_boundary(64)];
446        return truncated.to_string();
447    }
448
449    sanitized.to_string()
450}
451
452/// Resolve a unique filename in `dir`, appending `-1`, `-2`, etc. on collision.
453///
454/// If `dir/name` doesn't exist, returns it as-is. Otherwise increments a
455/// counter on the stem: `photo.jpg` → `photo-1.jpg` → `photo-2.jpg` ...
456pub fn resolve_unique_filename(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
457    let candidate = dir.join(name);
458    if !candidate.exists() {
459        return candidate;
460    }
461
462    let stem = std::path::Path::new(name)
463        .file_stem()
464        .and_then(|s| s.to_str())
465        .unwrap_or(name);
466    let ext = std::path::Path::new(name)
467        .extension()
468        .and_then(|s| s.to_str())
469        .unwrap_or("");
470
471    let mut counter = 1u32;
472    loop {
473        let suffixed = if ext.is_empty() {
474            format!("{}-{}", stem, counter)
475        } else {
476            format!("{}-{}.{}", stem, counter, ext)
477        };
478        let candidate = dir.join(&suffixed);
479        if !candidate.exists() {
480            return candidate;
481        }
482        counter += 1;
483    }
484}
485
486/// Decrypt a DM file attachment and save to the download directory.
487///
488/// Uses AES-GCM decryption with the key/nonce from the attachment metadata.
489/// Saves with atomic write (temp file + rename). Returns (path, content_hash).
490/// If an identical file already exists (same name + size + hash), reuses it.
491pub fn decrypt_and_save_attachment(
492    encrypted_data: &[u8],
493    key: &str,
494    nonce: &str,
495    name: &str,
496    extension: &str,
497) -> Result<(std::path::PathBuf, String), String> {
498    decrypt_and_save_attachment_owned(encrypted_data.to_vec(), key, nonce, name, extension)
499}
500
501/// [`decrypt_and_save_attachment`] for a caller that owns the download: it is
502/// decrypted in place rather than copied first.
503pub fn decrypt_and_save_attachment_owned(
504    encrypted_data: Vec<u8>,
505    key: &str,
506    nonce: &str,
507    name: &str,
508    extension: &str,
509) -> Result<(std::path::PathBuf, String), String> {
510    // Unencrypted foreign media (NIP-92 carries no decryption keys — those are
511    // Vector's own extension): the downloaded bytes ARE the plaintext, so skip
512    // AES-GCM and render best-effort. Hash/dedup/save below are identical either way.
513    let decrypted = if key.is_empty() || nonce.is_empty() {
514        encrypted_data
515    } else {
516        decrypt_data_owned(encrypted_data, key, nonce)?
517    };
518    let file_hash = sha256_hex(&decrypted);
519
520    let dir = crate::db::get_download_dir();
521    std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
522
523    let target_name = if name.is_empty() {
524        format!("{}.{}", file_hash, extension)
525    } else {
526        name.to_string()
527    };
528
529    // Content dedup: reuse if same name + size + hash
530    let candidate = dir.join(&target_name);
531    let already_exists = candidate.exists()
532        && std::fs::metadata(&candidate).map(|m| m.len() == decrypted.len() as u64).unwrap_or(false)
533        && std::fs::read(&candidate).map(|b| sha256_hex(&b) == file_hash).unwrap_or(false);
534
535    if already_exists {
536        return Ok((candidate, file_hash));
537    }
538
539    let file_path = resolve_unique_filename(&dir, &target_name);
540
541    // Atomic write: temp file then rename
542    let tmp_path = dir.join(format!(".{}.{}.tmp", file_hash, extension));
543    std::fs::write(&tmp_path, &decrypted).map_err(|e| format!("Failed to write file: {}", e))?;
544    std::fs::rename(&tmp_path, &file_path).map_err(|e| format!("Failed to rename file: {}", e))?;
545
546    Ok((file_path, file_hash))
547}
548
549/// Format bytes into human-readable format (KB, MB, GB).
550pub fn format_bytes(bytes: u64) -> String {
551    const KB: f64 = 1024.0;
552    const MB: f64 = KB * 1024.0;
553    const GB: f64 = MB * 1024.0;
554
555    if bytes < KB as u64 {
556        format!("{} B", bytes)
557    } else if bytes < MB as u64 {
558        format!("{:.1} KB", bytes as f64 / KB)
559    } else if bytes < GB as u64 {
560        format!("{:.1} MB", bytes as f64 / MB)
561    } else {
562        format!("{:.1} GB", bytes as f64 / GB)
563    }
564}
565
566/// Returns true if the provided MIME type is an image/*.
567pub fn is_image_mime(mime: &str) -> bool {
568    mime.trim().starts_with("image/")
569}
570
571/// Convert a file extension to a MIME type, with an optional restriction to image/* types.
572pub fn mime_from_extension_safe(extension: &str, image_only: bool) -> Result<String, String> {
573    let mime = mime_from_extension(extension).to_string();
574    if image_only && !is_image_mime(&mime) {
575        return Err(mime);
576    }
577    Ok(mime)
578}
579
580/// Detect MIME type from file magic bytes.
581/// Supports PNG, JPEG, GIF, WebP, TIFF, ICO, and SVG.
582/// Returns "application/octet-stream" for unrecognized formats.
583/// Every type [`mime_from_magic_bytes`] can name.
584///
585/// A consumer that judges media by its bytes needs to know what it can hope to
586/// recognise: a mime absent from this list is a type it will fetch, fail to
587/// identify, and be unable to answer for.
588pub const RECOGNISED_MIMES: &[&str] = &[
589    "image/png",
590    "image/jpeg",
591    "image/gif",
592    "image/webp",
593    "image/tiff",
594    "image/x-icon",
595    "image/svg+xml",
596    "image/bmp",
597    "video/webm",
598    "video/x-matroska",
599    "video/quicktime",
600    "video/mp4",
601];
602
603pub fn mime_from_magic_bytes(bytes: &[u8]) -> &'static str {
604    if bytes.len() < 4 {
605        return "application/octet-stream";
606    }
607    match bytes[0] {
608        0x89 if bytes[1..4] == [0x50, 0x4E, 0x47] => "image/png",
609        0xFF if bytes[1..3] == [0xD8, 0xFF] => "image/jpeg",
610        b'G' if bytes.len() >= 6 && (bytes[..6] == *b"GIF87a" || bytes[..6] == *b"GIF89a") => "image/gif",
611        b'R' if bytes.len() > 12 && bytes[..4] == *b"RIFF" && bytes[8..12] == *b"WEBP" => "image/webp",
612        0x49 if bytes[1..4] == [0x49, 0x2A, 0x00] => "image/tiff",
613        0x4D if bytes[1..4] == [0x4D, 0x00, 0x2A] => "image/tiff",
614        0x00 if bytes[1..4] == [0x00, 0x01, 0x00] => "image/x-icon",
615        b'<' if bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") => "image/svg+xml",
616        b'B' if bytes[1] == b'M' => "image/bmp",
617        // EBML: WebM and Matroska share it, so the DocType decides.
618        0x1A if bytes[1..4] == [0x45, 0xDF, 0xA3] => {
619            if bytes.len() >= 64 && bytes[..64].windows(4).any(|w| w == b"webm") {
620                "video/webm"
621            } else {
622                "video/x-matroska"
623            }
624        }
625        // ISO base media: the brand sits at offset 4, after a size field.
626        _ if bytes.len() >= 12 && bytes[4..8] == *b"ftyp" => match &bytes[8..12] {
627            b"qt  " => "video/quicktime",
628            _ => "video/mp4",
629        },
630        _ => "application/octet-stream",
631    }
632}
633
634// ============================================================================
635// Conditional Encryption — maybe_encrypt / maybe_decrypt
636// ============================================================================
637
638/// Check if a string looks like encrypted content (hex-encoded ChaCha20 output).
639/// Minimum (empty message): 12 + 0 + 16 = 28 bytes = 56 hex chars.
640#[inline]
641pub fn looks_encrypted(s: &str) -> bool {
642    if s.len() < 56 { return false; }
643    is_all_lowercase_hex(s.as_bytes())
644}
645
646/// NEON: check if all bytes are lowercase hex [0-9a-f].
647#[cfg(target_arch = "aarch64")]
648#[inline]
649fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
650    use std::arch::aarch64::*;
651    unsafe {
652        let mut i = 0;
653        while i + 16 <= bytes.len() {
654            let chunk = vld1q_u8(bytes.as_ptr().add(i));
655            let is_digit = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'0')),
656                                    vcleq_u8(chunk, vdupq_n_u8(b'9')));
657            let is_af = vandq_u8(vcgeq_u8(chunk, vdupq_n_u8(b'a')),
658                                 vcleq_u8(chunk, vdupq_n_u8(b'f')));
659            if vminvq_u8(vorrq_u8(is_digit, is_af)) == 0 { return false; }
660            i += 16;
661        }
662        while i < bytes.len() {
663            let b = bytes[i];
664            if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
665            i += 1;
666        }
667    }
668    true
669}
670
671/// SSE2: check if all bytes are lowercase hex [0-9a-f].
672#[cfg(target_arch = "x86_64")]
673#[inline]
674fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
675    use std::arch::x86_64::*;
676    unsafe {
677        let mut i = 0;
678        while i + 16 <= bytes.len() {
679            let chunk = _mm_loadu_si128(bytes.as_ptr().add(i) as *const __m128i);
680            let is_digit = _mm_cmpeq_epi8(
681                _mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'0' as i8)), _mm_set1_epi8(9)),
682                _mm_setzero_si128());
683            let is_af = _mm_cmpeq_epi8(
684                _mm_subs_epu8(_mm_sub_epi8(chunk, _mm_set1_epi8(b'a' as i8)), _mm_set1_epi8(5)),
685                _mm_setzero_si128());
686            if _mm_movemask_epi8(_mm_or_si128(is_digit, is_af)) != 0xFFFF { return false; }
687            i += 16;
688        }
689        while i < bytes.len() {
690            let b = bytes[i];
691            if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { return false; }
692            i += 1;
693        }
694    }
695    true
696}
697
698/// Scalar fallback for platforms without SIMD.
699#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
700#[inline]
701fn is_all_lowercase_hex(bytes: &[u8]) -> bool {
702    const IS_LOWER_HEX: [bool; 256] = {
703        let mut t = [false; 256];
704        t[b'0' as usize] = true; t[b'1' as usize] = true; t[b'2' as usize] = true;
705        t[b'3' as usize] = true; t[b'4' as usize] = true; t[b'5' as usize] = true;
706        t[b'6' as usize] = true; t[b'7' as usize] = true; t[b'8' as usize] = true;
707        t[b'9' as usize] = true; t[b'a' as usize] = true; t[b'b' as usize] = true;
708        t[b'c' as usize] = true; t[b'd' as usize] = true; t[b'e' as usize] = true;
709        t[b'f' as usize] = true;
710        t
711    };
712    bytes.iter().all(|&b| IS_LOWER_HEX[b as usize])
713}
714
715/// Encrypt a string using ENCRYPTION_KEY vault (ChaCha20-Poly1305).
716/// If `password` is Some, derives a key from it instead.
717pub async fn maybe_encrypt_inner(mut input: String, password: Option<String>) -> String {
718    let mut key: [u8; 32] = match password {
719        None => crate::state::ENCRYPTION_KEY.get().expect("Encryption key must be set"),
720        Some(password) => hash_pass(&password).await,
721    };
722
723    let framed = seal_framed(input.as_bytes(), &key).expect("Encryption should not fail");
724    // SAFETY: wiping leaves the String empty, which is valid UTF-8.
725    wipe_vec(unsafe { input.as_mut_vec() });
726
727    if !crate::state::ENCRYPTION_KEY.has_key() {
728        crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
729    }
730
731    key.zeroize();
732
733    crate::simd::hex::bytes_to_hex_string(&framed)
734}
735
736/// Decrypt a hex-encoded ChaCha20-Poly1305 ciphertext using ENCRYPTION_KEY vault.
737/// If `password` is Some, derives a key from it instead.
738pub async fn maybe_decrypt_inner(ciphertext: String, password: Option<String>) -> Result<String, ()> {
739    let has_password = password.is_some();
740
741    let mut key: [u8; 32] = if let Some(pass) = password {
742        hash_pass(&pass).await
743    } else {
744        match crate::state::ENCRYPTION_KEY.get() {
745            Some(k) => k,
746            None => return Err(()),
747        }
748    };
749
750    let Some(plaintext) = open_hex(&ciphertext, &key) else {
751        key.zeroize();
752        return Err(());
753    };
754
755    if has_password && !crate::state::ENCRYPTION_KEY.has_key() {
756        crate::state::ENCRYPTION_KEY.set(key, &[&crate::state::MY_SECRET_KEY]);
757    }
758
759    key.zeroize();
760    Ok(plaintext)
761}
762
763/// Conditionally encrypt content based on encryption_enabled setting.
764pub async fn maybe_encrypt(input: String) -> String {
765    if crate::state::is_encryption_enabled_fast() {
766        maybe_encrypt_inner(input, None).await
767    } else {
768        input
769    }
770}
771
772/// Conditionally decrypt content. Handles crash recovery — if decryption fails
773/// on non-encrypted-looking content, returns it as-is.
774pub async fn maybe_decrypt(input: String) -> Result<String, ()> {
775    // Only lowercase hex of at least nonce+tag can be our ciphertext, so anything
776    // else skips the vault read.
777    if !looks_encrypted(&input) {
778        return Ok(input);
779    }
780    if let Some(plaintext) = open_with_vault(&input) {
781        return Ok(plaintext);
782    }
783    if crate::state::is_encryption_enabled_fast() { Err(()) } else { Ok(input) }
784}
785
786fn open_with_vault(hex_data: &str) -> Option<String> {
787    let mut key = crate::state::ENCRYPTION_KEY.get()?;
788    let out = open_hex(hex_data, &key);
789    key.zeroize();
790    out
791}
792
793/// Opens many at-rest strings for one vault read and one key schedule, where
794/// `maybe_decrypt` pays both per string (the vault read is nearly all of it). Sync, and the
795/// opener cannot leave the closure, so the key never crosses an `await`.
796pub fn open_batch<R>(f: impl FnOnce(&mut AtRestOpener) -> R) -> R {
797    f(&mut AtRestOpener { cipher: None, loaded: false })
798}
799
800/// The per-string half of [`open_batch`]; the vault is read at the first ciphertext, so
801/// a batch of plaintext rows never touches it.
802pub struct AtRestOpener {
803    cipher: Option<ChaCha20Poly1305>,
804    loaded: bool,
805}
806
807impl AtRestOpener {
808    /// `maybe_decrypt` for one string, sharing the batch's cipher.
809    #[allow(clippy::result_unit_err)] // maybe_decrypt's own contract, which every caller already handles
810    pub fn open(&mut self, input: String) -> Result<String, ()> {
811        if !looks_encrypted(&input) {
812            return Ok(input);
813        }
814        if !self.loaded {
815            self.loaded = true;
816            if let Some(mut key) = crate::state::ENCRYPTION_KEY.get() {
817                self.cipher = Some(ChaCha20Poly1305::new(&key));
818                key.zeroize();
819            }
820        }
821        if let Some(plaintext) = self.cipher.as_ref().and_then(|c| open_hex_with(c, &input)) {
822            return Ok(plaintext);
823        }
824        if crate::state::is_encryption_enabled_fast() { Err(()) } else { Ok(input) }
825    }
826}
827
828// ============================================================================
829// Synchronous at-rest helpers (Concord tables, sync DB code)
830// ============================================================================
831//
832// `maybe_encrypt`/`maybe_decrypt` are async (they may derive a key from a
833// password). The Concord DB layer is synchronous and only ever uses the live
834// ENCRYPTION_KEY vault, so these sync variants wrap the field-level primitives
835// against the vault + the enabled flag. Discriminators for the half-migrated
836// case: a key BLOB is 32 bytes raw vs 12+len+16 encrypted; a text field uses
837// `looks_encrypted` (>=56 lowercase-hex chars).
838
839/// ChaCha20-Poly1305 encrypt raw bytes with an explicit key → `nonce(12) || ct || tag(16)`.
840pub fn encrypt_blob_with_key(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
841    seal_framed(plaintext, key).map_err(|e| format!("blob encryption failed: {}", e))
842}
843
844/// ChaCha20-Poly1305 decrypt `nonce(12) || ct || tag(16)` with an explicit key.
845pub fn decrypt_blob_with_key(stored: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, String> {
846    if stored.len() < NONCE_LEN + TAG_LEN {
847        return Err("blob too short to be ciphertext".to_string());
848    }
849    open_framed(stored.to_vec(), key).ok_or_else(|| "blob decryption failed (wrong key or corrupted)".to_string())
850}
851
852/// Encrypt a secret key BLOB for at-rest storage. Off → unchanged. Enabled but
853/// the vault is empty → Err (never silently persists a secret in plaintext).
854pub fn maybe_encrypt_blob(plaintext: &[u8]) -> Result<Vec<u8>, String> {
855    if !crate::state::is_encryption_enabled_fast() {
856        return Ok(plaintext.to_vec());
857    }
858    let mut key = crate::state::ENCRYPTION_KEY
859        .get()
860        .ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
861    let out = encrypt_blob_with_key(plaintext, &key);
862    key.zeroize();
863    out
864}
865
866/// Decrypt a secret key BLOB. Tolerant of the half-migrated DB: a 32-byte value
867/// is a raw (not-yet-wrapped / encryption-off) key, and anything that fails to
868/// authenticate is returned as-is, so a mixed store always reads back correctly.
869pub fn maybe_decrypt_blob(stored: &[u8]) -> Vec<u8> {
870    // 32 bytes = a raw key (encryption off, or a row written before the at-rest pass).
871    if stored.len() == 32 {
872        return stored.to_vec();
873    }
874    match crate::state::ENCRYPTION_KEY.get() {
875        Some(mut key) => {
876            let out = decrypt_blob_with_key(stored, &key).unwrap_or_else(|_| stored.to_vec());
877            key.zeroize();
878            out
879        }
880        None => stored.to_vec(),
881    }
882}
883
884/// Encrypt a text field for at-rest storage. Off → unchanged. Enabled but the
885/// vault is empty → Err.
886pub fn maybe_encrypt_text(plaintext: &str) -> Result<String, String> {
887    if !crate::state::is_encryption_enabled_fast() {
888        return Ok(plaintext.to_string());
889    }
890    let mut key = crate::state::ENCRYPTION_KEY
891        .get()
892        .ok_or_else(|| "encryption enabled but key vault is empty".to_string())?;
893    let out = encrypt_with_key(plaintext, &key);
894    key.zeroize();
895    out
896}
897
898/// Decrypt a text field. A value that doesn't look encrypted (or can't be
899/// decrypted) is returned as-is — pre-migration rows and encryption-off rows.
900pub fn maybe_decrypt_text(stored: &str) -> String {
901    if !looks_encrypted(stored) {
902        return stored.to_string();
903    }
904    match crate::state::ENCRYPTION_KEY.get() {
905        Some(mut key) => {
906            let out = decrypt_with_key(stored, &key).unwrap_or_else(|_| stored.to_string());
907            key.zeroize();
908            out
909        }
910        None => stored.to_string(),
911    }
912}
913
914#[cfg(test)]
915mod at_rest_batch_tests {
916    use super::*;
917
918    /// The vault and the enabled flag are process globals; the db-test guard serializes them.
919    fn with_vault<R>(key: Option<[u8; 32]>, enabled: bool, f: impl FnOnce() -> R) -> R {
920        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
921        match key {
922            Some(k) => crate::state::ENCRYPTION_KEY.set(k, &[]),
923            None => crate::state::ENCRYPTION_KEY.clear(&[]),
924        }
925        crate::state::set_encryption_enabled(enabled);
926        let out = f();
927        crate::state::set_encryption_enabled(false);
928        crate::state::ENCRYPTION_KEY.clear(&[]);
929        out
930    }
931
932    fn rows(key: &[u8; 32]) -> Vec<String> {
933        vec![
934            encrypt_with_key("first", key).unwrap(),
935            "plain row".to_string(),
936            encrypt_with_key("", key).unwrap(),
937            encrypt_with_key(&"long ".repeat(300), key).unwrap(),
938            encrypt_with_key("wrong key", &[9u8; 32]).unwrap(),
939            "cafe".repeat(20),
940        ]
941    }
942
943    #[test]
944    fn a_batch_answers_every_row_as_maybe_decrypt_does() {
945        use futures_util::FutureExt;
946        let key = [0x55u8; 32];
947        let rows = rows(&key);
948        for enabled in [true, false] {
949            with_vault(Some(key), enabled, || {
950                // maybe_decrypt never suspends, so it completes on the first poll.
951                let expected: Vec<_> = rows.iter().map(|r| maybe_decrypt(r.clone()).now_or_never().unwrap()).collect();
952                let batched = open_batch(|at_rest| rows.iter().map(|r| at_rest.open(r.clone())).collect::<Vec<_>>());
953                assert_eq!(batched, expected, "encryption enabled: {enabled}");
954            });
955        }
956    }
957
958    #[test]
959    fn plaintext_rows_never_need_the_key() {
960        let got = with_vault(None, true, || open_batch(|at_rest| vec![at_rest.open("hello".into()), at_rest.open("a b c".into())]));
961        assert_eq!(got, vec![Ok("hello".to_string()), Ok("a b c".to_string())]);
962    }
963
964    #[test]
965    fn a_ciphertext_with_no_key_in_the_vault_is_refused_while_encryption_is_on() {
966        let sealed = encrypt_with_key("secret", &[0x55u8; 32]).unwrap();
967        let on = with_vault(None, true, || open_batch(|at_rest| at_rest.open(sealed.clone())));
968        assert_eq!(on, Err(()));
969        let off = with_vault(None, false, || open_batch(|at_rest| at_rest.open(sealed.clone())));
970        assert_eq!(off, Ok(sealed), "encryption off: an undecryptable row reads through as-is");
971    }
972}
973
974#[cfg(test)]
975mod attachment_cipher_tests {
976    use super::*;
977
978    /// Ciphertexts sealed by aes-gcm 0.10.3: files already on Blossom servers must keep opening.
979    const PINNED: [(usize, &str); 6] = [
980        (0, "022f841c702b66b0bec8111f67de221d"),
981        (1, "292017643f9281af327f9487e3df3e3d7c"),
982        (15, "29a932a407ebe042f8dc56e1ba4ae84d9c12aa8198954368a48d9deebb7ab9"),
983        (16, "29a932a407ebe042f8dc56e1ba4ae8fc890ce0e3fa7321a07ba4f5bedc8e26e7"),
984        (17, "29a932a407ebe042f8dc56e1ba4ae8fcf1bed63597cf6053bc96b53c32185e0ecd"),
985        (100, "29a932a407ebe042f8dc56e1ba4ae8fcf13fc2f1f1a2cc0856a0d376defbf35bef7b179b6ad5b2a9f52b19922f7cf2bc4960c60abb73d0a56dabe7c406cb1555ca5427dad72a529c7f8f0c592ce2d0c49b46f9f9011bda808e760db92ad6ecc171a88a1ce072e4f68ec1b5cc3a5e23c37c645be7"),
986    ];
987
988    #[test]
989    fn attachment_cipher_matches_pinned_vectors() {
990        let params = EncryptionParams {
991            key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".to_string(),
992            nonce: "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf".to_string(),
993        };
994        for (len, want) in PINNED {
995            let pt: Vec<u8> = (0..len).map(|i| (i * 7 + 3) as u8).collect();
996            let sealed = encrypt_data(&pt, &params).unwrap();
997            assert_eq!(hex::encode(&sealed), want, "seal len={len}");
998            let ct = hex::decode(want).unwrap();
999            assert_eq!(decrypt_data(&ct, &params.key, &params.nonce).unwrap(), pt, "open len={len}");
1000        }
1001    }
1002}
1003
1004#[cfg(test)]
1005mod at_rest_tests {
1006    use super::*;
1007
1008    #[test]
1009    fn blob_roundtrip_with_explicit_key() {
1010        let key = [7u8; 32];
1011        let secret = [0x42u8; 32];
1012        let ct = encrypt_blob_with_key(&secret, &key).unwrap();
1013        assert_eq!(ct.len(), 12 + 32 + 16, "nonce + ciphertext + tag");
1014        assert_ne!(&ct[12..44], &secret[..], "ciphertext must not equal plaintext");
1015        assert_eq!(decrypt_blob_with_key(&ct, &key).unwrap(), secret.to_vec());
1016    }
1017
1018    #[test]
1019    fn blob_wrong_key_fails() {
1020        let ct = encrypt_blob_with_key(&[1u8; 32], &[7u8; 32]).unwrap();
1021        assert!(decrypt_blob_with_key(&ct, &[9u8; 32]).is_err());
1022    }
1023
1024    #[test]
1025    fn encrypted_text_is_always_detected_as_encrypted() {
1026        // Even the shortest fields ("[]", "{}", "") must exceed the looks_encrypted floor
1027        // so they round-trip through maybe_decrypt_text.
1028        let key = [3u8; 32];
1029        for s in ["", "[]", "{}", "a"] {
1030            let ct = encrypt_with_key(s, &key).unwrap();
1031            assert!(looks_encrypted(&ct), "ciphertext for {:?} must look encrypted", s);
1032            assert_eq!(decrypt_with_key(&ct, &key).unwrap(), s);
1033        }
1034    }
1035}
1036
1037// ============================================================================
1038// Image Metadata — thumbhash + dimensions for file attachments
1039// ============================================================================
1040
1041/// Re-export SIMD nearest-neighbor downsample from simd::image.
1042pub use crate::simd::image::nearest_neighbor_downsample_rgba;
1043
1044/// Generate a thumbhash from RGBA8 pixel data.
1045///
1046/// Downscales to fit within 100x100 (ThumbHash's max) using fast nearest-neighbor,
1047/// then hashes. Returns the base91-encoded thumbhash string.
1048pub fn generate_thumbhash_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<String> {
1049    use fast_thumbhash::{rgba_to_thumb_hash, base91_encode};
1050
1051    const MAX_DIM: u32 = 100;
1052
1053    let (thumb_w, thumb_h) = if width <= MAX_DIM && height <= MAX_DIM {
1054        (width, height)
1055    } else if width > height {
1056        (MAX_DIM, (MAX_DIM * height / width).max(1))
1057    } else {
1058        ((MAX_DIM * width / height).max(1), MAX_DIM)
1059    };
1060
1061    let thumbnail = if thumb_w == width && thumb_h == height {
1062        pixels.to_vec()
1063    } else {
1064        nearest_neighbor_downsample_rgba(pixels, width, height, thumb_w, thumb_h)
1065    };
1066
1067    let hash = rgba_to_thumb_hash(thumb_w as usize, thumb_h as usize, &thumbnail);
1068    Some(base91_encode(&hash))
1069}
1070
1071/// Decode an image with allocation limits. A tiny file declaring 60000×60000
1072/// would otherwise allocate ~14 GB before a single pixel decodes — every
1073/// decode of bytes we didn't author must go through this.
1074///
1075/// Applies EXIF orientation to the pixels. Camera photos store their pixels in
1076/// the sensor's native axis plus an orientation tag; `<img>` honours that tag,
1077/// but our re-encode strips all metadata, so an un-baked decode would ship the
1078/// sensor pixels with no tag and the receiver sees the photo rotated. Baking it
1079/// in here keeps every downstream path (compress, thumbhash, dimensions) upright.
1080pub fn decode_image_bounded(bytes: &[u8]) -> Result<image::DynamicImage, String> {
1081    use image::ImageDecoder;
1082    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
1083        .with_guessed_format()
1084        .map_err(|e| format!("image format: {e}"))?;
1085    reader.limits(bounded_image_limits());
1086    let mut decoder = reader.into_decoder().map_err(|e| format!("image decode: {e}"))?;
1087    // Read the tag before consuming the decoder; formats without one report NoTransforms.
1088    let orientation = decoder.orientation().map_err(|e| format!("image orientation: {e}"))?;
1089    let mut img = image::DynamicImage::from_decoder(decoder)
1090        .map_err(|e| format!("image decode: {e}"))?;
1091    img.apply_orientation(orientation);
1092    Ok(img)
1093}
1094
1095/// Shared decode limits for [`decode_image_bounded`] and call sites that need
1096/// their own `ImageReader` (fixed-format decodes).
1097pub fn bounded_image_limits() -> image::Limits {
1098    let mut limits = image::Limits::default();
1099    limits.max_image_width = Some(16_384);
1100    limits.max_image_height = Some(16_384);
1101    limits.max_alloc = Some(256 * 1024 * 1024);
1102    limits
1103}
1104
1105/// Generate image metadata (thumbhash + dimensions) from raw file bytes.
1106///
1107/// Returns None if the bytes can't be decoded as an image.
1108/// Used by `send_file_dm` to automatically include preview metadata for images.
1109pub fn generate_image_metadata(file_bytes: &[u8]) -> Option<crate::types::ImageMetadata> {
1110    let img = decode_image_bounded(file_bytes).ok()?;
1111    let width = img.width();
1112    let height = img.height();
1113
1114    // Shrink before converting: a full-resolution RGBA copy of a phone photo is
1115    // ~50 MB, for a hash that reads 100 pixels a side.
1116    let small = if width > 100 || height > 100 { img.thumbnail(100, 100) } else { img };
1117    let rgba = small.to_rgba8();
1118    let thumbhash = generate_thumbhash_from_rgba(rgba.as_raw(), rgba.width(), rgba.height())?;
1119
1120    Some(crate::types::ImageMetadata {
1121        thumbhash,
1122        width,
1123        height,
1124    })
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    #[test]
1130    fn a_senders_mime_can_only_name_a_plain_extension() {
1131        use super::extension_from_mime;
1132        assert_eq!(extension_from_mime("image/png"), "png");
1133        assert_eq!(extension_from_mime("application/x-bittorrent"), "bin");
1134        assert_eq!(extension_from_mime("audio/opus"), "opus");
1135        assert_eq!(extension_from_mime("x/.\\..\\..\\AppData\\Startup\\a.bat"), "bin");
1136        assert_eq!(extension_from_mime("x/../../etc"), "bin");
1137        assert_eq!(extension_from_mime("nothing"), "bin");
1138    }
1139
1140    use super::*;
1141
1142    /// A consumer that judges media by its BYTES needs the bytes to be
1143    /// readable. Video returning octet-stream meant every video was downloaded,
1144    /// decrypted and then discarded as an unknown type.
1145    #[test]
1146    fn magic_bytes_name_video_and_bitmaps() {
1147        let mut mp4 = vec![0u8, 0, 0, 0x20];
1148        mp4.extend_from_slice(b"ftypisom");
1149        assert_eq!(mime_from_magic_bytes(&mp4), "video/mp4");
1150
1151        let mut mov = vec![0u8, 0, 0, 0x14];
1152        mov.extend_from_slice(b"ftypqt  ");
1153        assert_eq!(mime_from_magic_bytes(&mov), "video/quicktime");
1154
1155        let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
1156        webm.extend_from_slice(&[0u8; 20]);
1157        webm.extend_from_slice(b"webm");
1158        webm.extend_from_slice(&[0u8; 40]);
1159        assert_eq!(mime_from_magic_bytes(&webm), "video/webm");
1160
1161        let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
1162        mkv.extend_from_slice(&[0u8; 64]);
1163        assert_eq!(mime_from_magic_bytes(&mkv), "video/x-matroska");
1164
1165        let mut bmp = b"BM".to_vec();
1166        bmp.extend_from_slice(&[0u8; 20]);
1167        assert_eq!(mime_from_magic_bytes(&bmp), "image/bmp");
1168    }
1169
1170    /// The list and the function must not drift: every entry has to be
1171    /// reachable, or a consumer trusts a type it will never be handed.
1172    #[test]
1173    fn every_recognised_mime_is_reachable_from_some_bytes() {
1174        let mut webm = vec![0x1A, 0x45, 0xDF, 0xA3];
1175        webm.extend_from_slice(&[0u8; 20]);
1176        webm.extend_from_slice(b"webm");
1177        webm.resize(80, 0);
1178        let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
1179        mkv.resize(80, 0);
1180        let mut webp = b"RIFF".to_vec();
1181        webp.extend_from_slice(&[0u8; 4]);
1182        webp.extend_from_slice(b"WEBP____");
1183
1184        let samples: Vec<Vec<u8>> = vec![
1185            vec![0x89, b'P', b'N', b'G'],
1186            vec![0xFF, 0xD8, 0xFF, 0xE0],
1187            b"GIF89a__".to_vec(),
1188            webp,
1189            vec![0x49, 0x49, 0x2A, 0x00],
1190            vec![0x00, 0x00, 0x01, 0x00],
1191            b"<svg xmlns".to_vec(),
1192            b"BM______".to_vec(),
1193            webm,
1194            mkv,
1195            b"\0\0\0\x14ftypqt  ".to_vec(),
1196            b"\0\0\0\x20ftypisom".to_vec(),
1197        ];
1198        let reachable: std::collections::HashSet<&str> =
1199            samples.iter().map(|b| mime_from_magic_bytes(b)).collect();
1200        for m in RECOGNISED_MIMES {
1201            assert!(reachable.contains(m), "{m} is listed but no bytes produce it");
1202        }
1203        assert_eq!(reachable.len(), RECOGNISED_MIMES.len(), "and nothing is produced that is not listed");
1204    }
1205
1206    /// The still-image answers must not have moved.
1207    #[test]
1208    fn magic_bytes_still_name_the_image_types() {
1209        assert_eq!(mime_from_magic_bytes(&[0x89, b'P', b'N', b'G']), "image/png");
1210        assert_eq!(mime_from_magic_bytes(&[0xFF, 0xD8, 0xFF, 0xE0]), "image/jpeg");
1211        assert_eq!(mime_from_magic_bytes(b"GIF89a__"), "image/gif");
1212        let mut webp = b"RIFF".to_vec();
1213        webp.extend_from_slice(&[0u8; 4]);
1214        webp.extend_from_slice(b"WEBP____");
1215        assert_eq!(mime_from_magic_bytes(&webp), "image/webp");
1216        assert_eq!(mime_from_magic_bytes(b"<svg xmlns"), "image/svg+xml");
1217        assert_eq!(mime_from_magic_bytes(&[0xDE, 0xAD, 0xBE, 0xEF]), "application/octet-stream");
1218        assert_eq!(mime_from_magic_bytes(&[]), "application/octet-stream", "a short read is not a type");
1219        assert_eq!(mime_from_magic_bytes(b"Bm"), "application/octet-stream", "and BMP is case-sensitive");
1220    }
1221
1222    // ========================================================================
1223    // encrypt_with_key / decrypt_with_key roundtrip tests
1224    // ========================================================================
1225
1226    fn test_key() -> [u8; 32] {
1227        [0x42u8; 32]
1228    }
1229
1230    fn alt_key() -> [u8; 32] {
1231        [0x99u8; 32]
1232    }
1233
1234    #[test]
1235    fn encrypt_decrypt_roundtrip_simple() {
1236        let key = test_key();
1237        let plaintext = "hello world";
1238        let encrypted = encrypt_with_key(plaintext, &key).expect("encryption should succeed");
1239        let decrypted = decrypt_with_key(&encrypted, &key).expect("decryption should succeed");
1240        assert_eq!(decrypted, plaintext, "roundtrip should preserve plaintext");
1241    }
1242
1243    #[test]
1244    fn encrypt_decrypt_100_random_strings() {
1245        use rand::Rng;
1246        let key = test_key();
1247        let mut rng = rand::thread_rng();
1248        for i in 0..100 {
1249            let len = rng.gen_range(1..=200);
1250            let s: String = (0..len).map(|_| rng.gen_range(0x20u8..0x7f) as char).collect();
1251            let enc = encrypt_with_key(&s, &key)
1252                .unwrap_or_else(|e| panic!("encryption failed on iteration {}: {}", i, e));
1253            let dec = decrypt_with_key(&enc, &key)
1254                .unwrap_or_else(|e| panic!("decryption failed on iteration {}: {}", i, e));
1255            assert_eq!(dec, s, "roundtrip failed on iteration {}", i);
1256        }
1257    }
1258
1259    #[test]
1260    fn encrypt_decrypt_empty_string() {
1261        let key = test_key();
1262        let encrypted = encrypt_with_key("", &key).expect("encrypting empty string should succeed");
1263        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting empty string should succeed");
1264        assert_eq!(decrypted, "", "empty string roundtrip should produce empty string");
1265    }
1266
1267    #[test]
1268    fn encrypt_decrypt_large_string() {
1269        let key = test_key();
1270        let plaintext = "A".repeat(10 * 1024); // 10 KB
1271        let encrypted = encrypt_with_key(&plaintext, &key).expect("encrypting 10KB should succeed");
1272        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting 10KB should succeed");
1273        assert_eq!(decrypted, plaintext, "10KB roundtrip should preserve content");
1274    }
1275
1276    #[test]
1277    fn decrypt_with_wrong_key_fails() {
1278        let key = test_key();
1279        let wrong_key = alt_key();
1280        let encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
1281        let result = decrypt_with_key(&encrypted, &wrong_key);
1282        assert!(result.is_err(), "decryption with wrong key should fail");
1283    }
1284
1285    #[test]
1286    fn decrypt_corrupted_ciphertext_fails() {
1287        let key = test_key();
1288        let mut encrypted = encrypt_with_key("secret data", &key).expect("encryption should succeed");
1289        // Corrupt a byte in the ciphertext portion (past the 24-char nonce)
1290        let bytes: Vec<u8> = encrypted.bytes().collect();
1291        if bytes.len() > 30 {
1292            let mut chars: Vec<char> = encrypted.chars().collect();
1293            // Flip a hex digit in the ciphertext area
1294            chars[30] = if chars[30] == '0' { 'f' } else { '0' };
1295            encrypted = chars.into_iter().collect();
1296        }
1297        let result = decrypt_with_key(&encrypted, &key);
1298        assert!(result.is_err(), "decryption of corrupted ciphertext should fail");
1299    }
1300
1301    #[test]
1302    fn different_keys_produce_different_ciphertext() {
1303        let key1 = test_key();
1304        let key2 = alt_key();
1305        let plaintext = "same plaintext";
1306        let enc1 = encrypt_with_key(plaintext, &key1).expect("enc1 should succeed");
1307        let enc2 = encrypt_with_key(plaintext, &key2).expect("enc2 should succeed");
1308        // Ciphertexts after the nonce portion should differ (nonces differ too since random)
1309        assert_ne!(enc1, enc2, "different keys should produce different ciphertext");
1310    }
1311
1312    #[test]
1313    fn nonce_is_always_different() {
1314        let key = test_key();
1315        let plaintext = "same string encrypted twice";
1316        let enc1 = encrypt_with_key(plaintext, &key).expect("enc1 should succeed");
1317        let enc2 = encrypt_with_key(plaintext, &key).expect("enc2 should succeed");
1318        // The first 24 hex chars are the nonce
1319        let nonce1 = &enc1[..24];
1320        let nonce2 = &enc2[..24];
1321        assert_ne!(nonce1, nonce2, "nonces should differ between encryptions of the same plaintext");
1322    }
1323
1324    #[test]
1325    fn unicode_content_preserved() {
1326        let key = test_key();
1327        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}";
1328        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting unicode should succeed");
1329        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting unicode should succeed");
1330        assert_eq!(decrypted, plaintext, "unicode content should be preserved through encrypt/decrypt");
1331    }
1332
1333    #[test]
1334    fn decrypt_too_short_ciphertext_fails() {
1335        let key = test_key();
1336        let result = decrypt_with_key("abcdef", &key);
1337        assert!(result.is_err(), "ciphertext shorter than 24 hex chars should fail");
1338        assert!(result.unwrap_err().contains("too short"), "error should mention too short");
1339    }
1340
1341    #[test]
1342    fn encrypt_output_is_hex_encoded() {
1343        let key = test_key();
1344        let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
1345        assert!(encrypted.chars().all(|c| c.is_ascii_hexdigit()),
1346            "encrypted output should be entirely hex characters");
1347    }
1348
1349    #[test]
1350    fn encrypt_output_has_correct_structure() {
1351        let key = test_key();
1352        let encrypted = encrypt_with_key("test", &key).expect("encryption should succeed");
1353        // Must be at least 24 hex chars (nonce) + some ciphertext
1354        assert!(encrypted.len() > 24,
1355            "encrypted output should have nonce (24 hex chars) plus ciphertext");
1356        // Length should be even (hex pairs)
1357        assert_eq!(encrypted.len() % 2, 0,
1358            "encrypted output length should be even (hex pairs)");
1359    }
1360
1361    #[test]
1362    fn encrypt_decrypt_special_characters() {
1363        let key = test_key();
1364        let plaintext = r#"!@#$%^&*()_+-=[]{}|;':",.<>?/\`~"#;
1365        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting special chars should succeed");
1366        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting special chars should succeed");
1367        assert_eq!(decrypted, plaintext, "special characters should survive roundtrip");
1368    }
1369
1370    #[test]
1371    fn encrypt_decrypt_newlines_and_whitespace() {
1372        let key = test_key();
1373        let plaintext = "line1\nline2\r\nline3\ttab\0null";
1374        let encrypted = encrypt_with_key(plaintext, &key).expect("encrypting whitespace should succeed");
1375        let decrypted = decrypt_with_key(&encrypted, &key).expect("decrypting whitespace should succeed");
1376        assert_eq!(decrypted, plaintext, "whitespace and control chars should survive roundtrip");
1377    }
1378
1379    #[test]
1380    fn decrypt_invalid_hex_in_nonce_fails() {
1381        let key = test_key();
1382        // 24 chars of invalid hex + some ciphertext
1383        let bad = "zzzzzzzzzzzzzzzzzzzzzzzz0000000000000000";
1384        let result = decrypt_with_key(bad, &key);
1385        assert!(result.is_err(), "invalid hex in nonce should fail");
1386    }
1387
1388    #[test]
1389    fn decrypt_invalid_hex_in_ciphertext_fails() {
1390        let key = test_key();
1391        // Valid nonce (24 hex chars) + invalid ciphertext hex
1392        let bad = "000000000000000000000000gggggggg";
1393        let result = decrypt_with_key(bad, &key);
1394        assert!(result.is_err(), "invalid hex in ciphertext should fail");
1395    }
1396
1397    // ========================================================================
1398    // hex module tests
1399    // ========================================================================
1400
1401    #[test]
1402    fn hex_encode_decode_roundtrip() {
1403        let data = vec![0x00, 0x01, 0xff, 0x80, 0x7f];
1404        let encoded = hex::encode(&data);
1405        let decoded = hex::decode(&encoded).expect("decode should succeed");
1406        assert_eq!(decoded, data, "hex encode/decode roundtrip should preserve bytes");
1407    }
1408
1409    #[test]
1410    fn hex_decode_odd_length_error() {
1411        let result = hex::decode("abc");
1412        assert!(result.is_err(), "odd-length hex string should fail");
1413        assert!(result.unwrap_err().contains("Odd-length"), "error should mention odd-length");
1414    }
1415
1416    #[test]
1417    fn hex_decode_invalid_hex_error() {
1418        let result = hex::decode("zzzz");
1419        assert!(result.is_err(), "invalid hex characters should fail");
1420    }
1421
1422    #[test]
1423    fn hex_encode_empty() {
1424        assert_eq!(hex::encode(&[]), "", "encoding empty bytes should produce empty string");
1425    }
1426
1427    #[test]
1428    fn hex_decode_empty() {
1429        let decoded = hex::decode("").expect("decoding empty string should succeed");
1430        assert!(decoded.is_empty(), "decoding empty string should produce empty vec");
1431    }
1432
1433    // ========================================================================
1434    // hash_pass tests
1435    // ========================================================================
1436
1437    #[tokio::test]
1438    async fn hash_pass_deterministic() {
1439        let key1 = hash_pass("my_password").await;
1440        let key2 = hash_pass("my_password").await;
1441        assert_eq!(key1, key2, "same password should always produce the same key");
1442    }
1443
1444    #[tokio::test]
1445    async fn hash_pass_different_passwords_different_keys() {
1446        let key1 = hash_pass("password_one").await;
1447        let key2 = hash_pass("password_two").await;
1448        assert_ne!(key1, key2, "different passwords should produce different keys");
1449    }
1450
1451    #[tokio::test]
1452    async fn hash_pass_output_is_32_bytes() {
1453        let key = hash_pass("test_password").await;
1454        assert_eq!(key.len(), 32, "hash_pass should produce exactly 32 bytes");
1455        // Ensure it is not all zeros (i.e. hashing actually happened)
1456        assert!(key.iter().any(|&b| b != 0), "hash output should not be all zeros");
1457    }
1458
1459    #[test]
1460    fn generate_thumbhash_from_bytes_basic() {
1461        // 2x2 red pixel image in RGBA
1462        let pixels: Vec<u8> = vec![
1463            255, 0, 0, 255,  255, 0, 0, 255,
1464            255, 0, 0, 255,  255, 0, 0, 255,
1465        ];
1466        let result = super::generate_thumbhash_from_rgba(&pixels, 2, 2);
1467        assert!(result.is_some());
1468        assert!(!result.unwrap().is_empty());
1469    }
1470
1471    #[test]
1472    fn generate_image_metadata_from_bytes() {
1473        // Create a 4x4 blue PNG in memory
1474        let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 255, 255]));
1475        let mut buf = Vec::new();
1476        let mut cursor = std::io::Cursor::new(&mut buf);
1477        img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
1478
1479        let meta = super::generate_image_metadata(&buf);
1480        assert!(meta.is_some());
1481        let meta = meta.unwrap();
1482        assert_eq!(meta.width, 4);
1483        assert_eq!(meta.height, 4);
1484        assert!(!meta.thumbhash.is_empty());
1485    }
1486
1487    #[test]
1488    fn generate_image_metadata_non_image() {
1489        let text_bytes = b"this is not an image";
1490        let meta = super::generate_image_metadata(text_bytes);
1491        assert!(meta.is_none());
1492    }
1493}