use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use fs2::FileExt;
use sha2::{Digest, Sha256};
use crate::error::LificError;
pub(crate) const STORE_LOCK_FILE: &str = ".lific-attachments.lock";
fn lock_is_busy(error: &std::io::Error) -> bool {
if error.kind() == std::io::ErrorKind::WouldBlock {
return true;
}
#[cfg(windows)]
{
error.raw_os_error() == Some(33)
}
#[cfg(not(windows))]
{
false
}
}
#[derive(Debug, Clone)]
pub struct AttachmentStore {
dir: PathBuf,
lock_path: PathBuf,
operation_lock: Arc<Mutex<()>>,
}
fn existing_regular_file(path: &Path, label: &str) -> Result<bool, LificError> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(LificError::Internal(format!(
"inspect {label} path: {error}"
)));
}
};
if !metadata.file_type().is_file() {
return Err(LificError::Internal(format!(
"{label} path is not a regular file"
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(LificError::Internal(format!("{label} path is hard-linked")));
}
}
Ok(true)
}
#[cfg_attr(
not(unix),
expect(clippy::unnecessary_wraps, reason = "fallible on Unix")
)]
fn sync_dir(_dir: &Path) -> Result<(), LificError> {
#[cfg(unix)]
{
std::fs::File::open(_dir)
.and_then(|dir| dir.sync_all())
.map_err(|e| LificError::Internal(format!("sync directory: {e}")))?;
}
Ok(())
}
pub(crate) fn valid_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
impl AttachmentStore {
pub fn from_db_path(db_path: &Path) -> Self {
let data_dir = match db_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
Self {
dir: data_dir.join("attachments"),
lock_path: data_dir.join(STORE_LOCK_FILE),
operation_lock: Arc::new(Mutex::new(())),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(dir: PathBuf) -> Self {
Self {
lock_path: dir.join(STORE_LOCK_FILE),
dir,
operation_lock: Arc::new(Mutex::new(())),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn dir(&self) -> &Path {
&self.dir
}
pub(crate) fn path_for(&self, sha256: &str) -> Result<PathBuf, LificError> {
if !valid_sha256(sha256) {
return Err(LificError::BadRequest(
"attachment hash must be 64 lowercase hexadecimal characters".into(),
));
}
Ok(self.dir.join(sha256))
}
pub(crate) fn thumb_path_for(&self, sha256: &str) -> Result<PathBuf, LificError> {
self.path_for(sha256)?;
Ok(self.dir.join("thumbs").join(format!("{sha256}.webp")))
}
pub fn write_thumb(&self, sha256: &str, bytes: &[u8]) -> Result<(), LificError> {
let path = self.thumb_path_for(sha256)?;
let parent = path
.parent()
.ok_or_else(|| LificError::Internal("thumbnail path has no parent".into()))?;
std::fs::create_dir_all(parent)
.map_err(|e| LificError::Internal(format!("create thumbnails dir: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|e| LificError::Internal(format!("secure thumbnails dir: {e}")))?;
}
let tmp = parent.join(format!(".{}.{}.tmp", sha256, rand::random::<u64>()));
if existing_regular_file(&path, "thumbnail")? {
return Ok(());
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = options
.open(&tmp)
.map_err(|e| LificError::Internal(format!("create thumbnail: {e}")))?;
let result = (|| -> std::io::Result<()> {
std::io::Write::write_all(&mut file, bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&tmp, &path)
})();
if let Err(error) = result {
let _ = std::fs::remove_file(&tmp);
return Err(LificError::Internal(format!("write thumbnail: {error}")));
}
sync_dir(parent)?;
Ok(())
}
pub fn read_thumb(&self, sha256: &str) -> Result<Option<Vec<u8>>, LificError> {
match std::fs::read(self.thumb_path_for(sha256)?) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(LificError::Internal(format!("read thumbnail: {e}"))),
}
}
pub fn delete_thumb(&self, sha256: &str) -> Result<(), LificError> {
match std::fs::remove_file(self.thumb_path_for(sha256)?) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(LificError::Internal(format!("delete thumbnail: {e}"))),
}
}
pub(crate) fn blob_exists(&self, sha256: &str) -> Result<bool, LificError> {
existing_regular_file(&self.path_for(sha256)?, "attachment")
}
pub(crate) fn thumb_exists(&self, sha256: &str) -> Result<bool, LificError> {
existing_regular_file(&self.thumb_path_for(sha256)?, "thumbnail")
}
pub fn hash_bytes(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
crate::auth::hex_encode(&digest)
}
pub(crate) fn lock_path(&self) -> &Path {
&self.lock_path
}
fn open_lock_file(&self) -> std::io::Result<File> {
if let Some(parent) = self.lock_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options.open(self.lock_path())
}
fn acquire_file_lock(&self) -> std::io::Result<File> {
let file = self.open_lock_file()?;
FileExt::lock_exclusive(&file)?;
Ok(file)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn lock_is_held(&self) -> bool {
let Ok(file) = self.open_lock_file() else {
return true;
};
match file.try_lock_exclusive() {
Ok(()) => {
let _ = FileExt::unlock(&file);
false
}
Err(_) => true,
}
}
pub(crate) fn with_lock<T>(
&self,
operation: impl FnOnce(&Self) -> Result<T, LificError>,
) -> Result<T, LificError> {
let _guard = self
.operation_lock
.lock()
.map_err(|_| LificError::Internal("attachment store lock poisoned".into()))?;
let _file_lock = self
.acquire_file_lock()
.map_err(|e| LificError::Internal(format!("lock attachment store: {e}")))?;
operation(self)
}
pub(crate) fn try_with_lock<T>(
&self,
operation: impl FnOnce(&Self) -> Result<T, LificError>,
) -> Result<Option<T>, LificError> {
let _guard = match self.operation_lock.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
Err(std::sync::TryLockError::Poisoned(_)) => {
return Err(LificError::Internal(
"attachment store lock poisoned".into(),
));
}
};
let file = self
.open_lock_file()
.map_err(|e| LificError::Internal(format!("lock attachment store: {e}")))?;
match file.try_lock_exclusive() {
Ok(()) => {}
Err(error) if lock_is_busy(&error) => return Ok(None),
Err(error) => {
return Err(LificError::Internal(format!(
"lock attachment store: {error}"
)));
}
}
let result = operation(self);
let _ = FileExt::unlock(&file);
result.map(Some)
}
pub(crate) fn try_with_string_lock<T>(
&self,
operation: impl FnOnce(&Self) -> Result<T, String>,
) -> Result<Option<T>, String> {
let _guard = match self.operation_lock.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
Err(std::sync::TryLockError::Poisoned(_)) => {
return Err("attachment store lock poisoned".to_string());
}
};
let file = self
.open_lock_file()
.map_err(|error| format!("lock attachment store: {error}"))?;
match file.try_lock_exclusive() {
Ok(()) => {}
Err(error) if lock_is_busy(&error) => return Ok(None),
Err(error) => return Err(format!("lock attachment store: {error}")),
}
let result = operation(self);
let _ = FileExt::unlock(&file);
result.map(Some)
}
pub(crate) fn busy_error() -> LificError {
LificError::Unavailable(
"attachment storage is busy (a backup or restore is running); retry shortly".into(),
)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn write(&self, bytes: &[u8]) -> Result<String, LificError> {
self.with_lock(|store| store.write_unlocked(bytes))
}
pub(crate) fn write_unlocked(&self, bytes: &[u8]) -> Result<String, LificError> {
let sha = Self::hash_bytes(bytes);
std::fs::create_dir_all(&self.dir)
.map_err(|e| LificError::Internal(format!("create attachments dir: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700))
.map_err(|e| LificError::Internal(format!("secure attachments dir: {e}")))?;
}
let path = self.path_for(&sha)?;
if existing_regular_file(&path, "attachment")? {
return Ok(sha);
}
let tmp = self
.dir
.join(format!(".{sha}.{:016x}.tmp", rand::random::<u64>()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = options
.open(&tmp)
.map_err(|e| LificError::Internal(format!("create attachment temp file: {e}")))?;
if let Err(error) = std::io::Write::write_all(&mut file, bytes) {
let _ = std::fs::remove_file(&tmp);
return Err(LificError::Internal(format!("write attachment: {error}")));
}
if let Err(error) = file.sync_all() {
let _ = std::fs::remove_file(&tmp);
return Err(LificError::Internal(format!("sync attachment: {error}")));
}
drop(file);
if let Err(error) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(LificError::Internal(format!(
"finalize attachment: {error}"
)));
}
sync_dir(&self.dir)?;
Ok(sha)
}
pub fn read(&self, sha256: &str) -> Result<Vec<u8>, LificError> {
let path = self.path_for(sha256)?;
std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
LificError::NotFound("attachment bytes not found on disk".into())
} else {
LificError::Internal(format!("read attachment: {e}"))
}
})
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn delete(&self, sha256: &str) -> Result<(), LificError> {
self.with_lock(|store| store.delete_unlocked(sha256))
}
pub(crate) fn delete_unlocked(&self, sha256: &str) -> Result<(), LificError> {
let _ = self.delete_thumb(sha256);
let path = self.path_for(sha256)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(LificError::Internal(format!("delete attachment: {e}"))),
}
}
}
pub const ORPHAN_GRACE_SECONDS: i64 = 24 * 60 * 60;
pub fn sweep_orphans(
pool: &crate::db::DbPool,
store: &AttachmentStore,
grace_seconds: i64,
) -> Result<usize, LificError> {
use crate::db::queries::attachments as q;
let orphans = {
let conn = pool.read()?;
q::find_orphans(&conn, grace_seconds)?
};
let mut collected = 0;
for orphan in orphans {
let removed = store.with_lock(|store| {
let conn = pool.write()?;
let Some(sha256) = q::delete_orphan_attachment(&conn, orphan.id)? else {
return Ok(false);
};
if q::count_rows_for_sha(&conn, &sha256)? == 0 {
store.delete_unlocked(&sha256)?;
}
Ok(true)
})?;
if removed {
collected += 1;
}
}
Ok(collected)
}
pub fn backfill_attachment_text(
pool: &crate::db::DbPool,
store: &AttachmentStore,
) -> Result<usize, LificError> {
use crate::db::queries::attachments as q;
let pending = {
let conn = pool.read()?;
q::unindexed_text_attachments(&conn)?
};
let mut indexed = 0;
for (id, sha256) in pending {
let Ok(bytes) = store.read(&sha256) else {
continue;
};
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
if text.is_empty() {
continue;
}
{
let conn = pool.write()?;
q::set_extracted_text(&conn, id, &text)?;
}
indexed += 1;
}
Ok(indexed)
}
pub fn start_gc_task(
pool: crate::db::DbPool,
store: AttachmentStore,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
run_blocking("attachment text backfill", {
let pool = pool.clone();
let store = store.clone();
move || match backfill_attachment_text(&pool, &store) {
Ok(n) if n > 0 => {
tracing::info!(indexed = n, "attachment text backfill indexed files")
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "attachment text backfill failed"),
}
})
.await;
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
interval.tick().await; loop {
interval.tick().await;
run_blocking("attachment GC sweep", {
let pool = pool.clone();
let store = store.clone();
move || match sweep_orphans(&pool, &store, ORPHAN_GRACE_SECONDS) {
Ok(n) if n > 0 => {
tracing::info!(collected = n, "attachment GC swept orphans")
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "attachment GC sweep failed"),
}
})
.await;
}
})
}
async fn run_blocking(label: &'static str, job: impl FnOnce() + Send + 'static) {
if let Err(e) = tokio::task::spawn_blocking(job).await {
tracing::error!(job = label, error = %e, "background job failed to run to completion");
}
}
pub const ALLOWED_MIMES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"text/plain",
"application/zip",
"video/mp4",
"video/webm",
"audio/webm",
"audio/ogg",
"audio/mpeg",
"application/vnd.sqlite3",
];
pub const RASTER_MIMES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
pub fn is_raster_mime(mime: &str) -> bool {
RASTER_MIMES.contains(&mime)
}
pub fn is_inline_safe_mime(mime: &str) -> bool {
matches!(
mime,
"image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "video/mp4"
| "video/webm"
| "audio/webm"
| "audio/ogg"
| "audio/mpeg"
)
}
pub fn sniff_and_validate(bytes: &[u8], declared: Option<&str>) -> Result<String, LificError> {
match classify_prefix(prefix_of(bytes), declared) {
PrefixVerdict::Decided(mime) => Ok(mime),
PrefixVerdict::Rejected(error) => Err(error),
PrefixVerdict::TextIfUtf8 => {
if std::str::from_utf8(bytes).is_ok() {
Ok("text/plain".to_string())
} else {
Err(unrecognized_type())
}
}
}
}
pub(crate) const SNIFF_PREFIX_BYTES: usize = 4096;
fn prefix_of(bytes: &[u8]) -> &[u8] {
&bytes[..bytes.len().min(SNIFF_PREFIX_BYTES)]
}
fn unrecognized_type() -> LificError {
LificError::BadRequest("rejected: unsupported or unrecognized file type".into())
}
enum PrefixVerdict {
Decided(String),
Rejected(LificError),
TextIfUtf8,
}
fn classify_prefix(prefix: &[u8], declared: Option<&str>) -> PrefixVerdict {
let declared = declared.map(|d| d.split(';').next().unwrap_or(d).trim().to_ascii_lowercase());
if let Some(mime) = sniff_magic(prefix) {
if mime == "video/webm" && declared.as_deref() == Some("audio/webm") {
return PrefixVerdict::Decided("audio/webm".to_string());
}
return PrefixVerdict::Decided(mime.to_string());
}
if looks_executable(prefix) {
return PrefixVerdict::Rejected(LificError::BadRequest(
"rejected: file looks like an executable".into(),
));
}
if declared.as_deref() == Some("image/svg+xml") && looks_like_svg(prefix) {
return PrefixVerdict::Decided("image/svg+xml".to_string());
}
if prefix.is_empty() {
return PrefixVerdict::Rejected(unrecognized_type());
}
PrefixVerdict::TextIfUtf8
}
pub fn sniff_and_validate_stream<R: Read>(
mut reader: R,
declared: Option<&str>,
) -> Result<String, LificError> {
let mut prefix = vec![0u8; SNIFF_PREFIX_BYTES];
let filled = read_fully(&mut reader, &mut prefix)
.map_err(|e| LificError::BadRequest(format!("read attachment: {e}")))?;
prefix.truncate(filled);
match classify_prefix(&prefix, declared) {
PrefixVerdict::Decided(mime) => Ok(mime),
PrefixVerdict::Rejected(error) => Err(error),
PrefixVerdict::TextIfUtf8 => {
let valid = stream_is_utf8(&prefix, reader)
.map_err(|e| LificError::BadRequest(format!("read attachment: {e}")))?;
if valid {
Ok("text/plain".to_string())
} else {
Err(unrecognized_type())
}
}
}
}
fn read_fully<R: Read>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(filled)
}
const UTF8_STREAM_CHUNK: usize = 64 * 1024;
fn stream_is_utf8<R: Read>(prefix: &[u8], mut reader: R) -> std::io::Result<bool> {
let mut carry: Vec<u8> = Vec::with_capacity(4);
if !feed_utf8(&mut carry, prefix) {
return Ok(false);
}
let mut buf = vec![0u8; UTF8_STREAM_CHUNK];
loop {
let read = match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if !feed_utf8(&mut carry, &buf[..read]) {
return Ok(false);
}
}
Ok(carry.is_empty())
}
fn feed_utf8(carry: &mut Vec<u8>, chunk: &[u8]) -> bool {
let joined: Vec<u8>;
let bytes: &[u8] = if carry.is_empty() {
chunk
} else {
joined = carry.iter().copied().chain(chunk.iter().copied()).collect();
&joined
};
match std::str::from_utf8(bytes) {
Ok(_) => {
carry.clear();
true
}
Err(error) if error.error_len().is_none() => {
let tail = bytes[error.valid_up_to()..].to_vec();
if tail.len() > 3 {
return false;
}
*carry = tail;
true
}
Err(_) => false,
}
}
fn sniff_magic(bytes: &[u8]) -> Option<&'static str> {
if bytes.len() >= 8 && bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if bytes.len() >= 3 && bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
return Some("image/gif");
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if bytes.len() >= 5 && bytes.starts_with(b"%PDF-") {
return Some("application/pdf");
}
if bytes.len() >= 4
&& bytes[0] == 0x50
&& bytes[1] == 0x4B
&& (bytes[2] == 0x03 || bytes[2] == 0x05 || bytes[2] == 0x07)
{
return Some("application/zip");
}
if bytes.starts_with(SQLITE_MAGIC) {
return Some("application/vnd.sqlite3");
}
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
let brand = &bytes[8..12];
if brand != b"qt " {
return Some("video/mp4");
}
return None;
}
if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
let head = &bytes[..bytes.len().min(64)];
if head.windows(4).any(|w| w == b"webm") {
return Some("video/webm");
}
return None;
}
if bytes.starts_with(b"OggS") {
return Some("audio/ogg");
}
if bytes.starts_with(b"ID3") {
return Some("audio/mpeg");
}
if bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 {
let version = (bytes[1] >> 3) & 0b11; let layer = (bytes[1] >> 1) & 0b11; if version != 0b01 && layer != 0b00 {
return Some("audio/mpeg");
}
}
None
}
pub const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
fn looks_executable(bytes: &[u8]) -> bool {
const SIGS: &[&[u8]] = &[
b"\x7FELF",
&[0xFE, 0xED, 0xFA, 0xCE],
&[0xFE, 0xED, 0xFA, 0xCF],
&[0xCF, 0xFA, 0xED, 0xFE],
&[0xCE, 0xFA, 0xED, 0xFE],
b"MZ",
&[0xCA, 0xFE, 0xBA, 0xBE],
b"#!",
&[0x00, 0x61, 0x73, 0x6D], ];
SIGS.iter().any(|sig| bytes.starts_with(sig))
}
pub const THUMBNAIL_MAX_EDGE: u32 = 480;
const MAX_DECODE_PIXELS: u64 = 50_000_000;
fn reader_for(
bytes: &[u8],
) -> Result<image::ImageReader<std::io::Cursor<&[u8]>>, image::ImageError> {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(image::ImageError::IoError)?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(20_000);
limits.max_image_height = Some(20_000);
limits.max_alloc = Some(MAX_DECODE_PIXELS * 4);
reader.limits(limits);
Ok(reader)
}
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
reader_for(bytes).ok()?.into_dimensions().ok()
}
pub fn generate_thumbnail(bytes: &[u8]) -> Result<Option<Vec<u8>>, LificError> {
let reader =
reader_for(bytes).map_err(|e| LificError::BadRequest(format!("undecodable image: {e}")))?;
let (w, h) = reader
.into_dimensions()
.map_err(|e| LificError::BadRequest(format!("undecodable image: {e}")))?;
if w.max(h) <= THUMBNAIL_MAX_EDGE {
return Ok(None);
}
if u64::from(w) * u64::from(h) > MAX_DECODE_PIXELS {
return Err(LificError::BadRequest(
"image is too large to thumbnail".into(),
));
}
let image = reader_for(bytes)
.and_then(|r| r.decode())
.map_err(|e| LificError::BadRequest(format!("undecodable image: {e}")))?;
let small = image.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE);
let rgba = small.to_rgba8();
let (tw, th) = (rgba.width(), rgba.height());
let mut out = Vec::new();
image::codecs::webp::WebPEncoder::new_lossless(std::io::Cursor::new(&mut out))
.encode(rgba.as_raw(), tw, th, image::ExtendedColorType::Rgba8)
.map_err(|e| LificError::Internal(format!("encode thumbnail: {e}")))?;
Ok(Some(out))
}
pub fn expects_thumbnail(mime: &str, width: Option<i64>, height: Option<i64>) -> bool {
if !is_raster_mime(mime) {
return false;
}
let long_edge = width.unwrap_or(0).max(height.unwrap_or(0));
long_edge > i64::from(THUMBNAIL_MAX_EDGE)
}
fn looks_like_svg(bytes: &[u8]) -> bool {
let head_len = bytes.len().min(512);
let head = String::from_utf8_lossy(&bytes[..head_len]).to_ascii_lowercase();
head.contains("<svg") || head.trim_start().starts_with("<?xml")
}
#[cfg(test)]
pub(crate) mod fixtures {
pub(crate) fn png_image(width: u32, height: u32) -> Vec<u8> {
let buffer = image::RgbaImage::from_pixel(width, height, image::Rgba([9, 40, 90, 255]));
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.expect("encode png fixture");
out
}
}
#[cfg(test)]
mod tests {
use super::fixtures::png_image;
use super::*;
fn tmp_store() -> (AttachmentStore, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let store = AttachmentStore::new(tmp.path().join("attachments"));
(store, tmp)
}
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
const LOCK_BLOCK_PROBE: std::time::Duration = std::time::Duration::from_millis(250);
#[test]
fn independently_constructed_stores_share_the_on_disk_lock() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("attachments");
let holder = AttachmentStore::new(dir.clone());
let observer = AttachmentStore::new(dir);
assert!(
!Arc::ptr_eq(&holder.operation_lock, &observer.operation_lock),
"the two stores must not be sharing an in-process mutex"
);
assert!(!observer.lock_is_held(), "nothing holds the lock yet");
let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel::<()>(1);
let (release_tx, release_rx) = std::sync::mpsc::sync_channel::<()>(1);
let worker = std::thread::spawn(move || {
holder.with_lock(|_| {
entered_tx.send(()).unwrap();
let _ = release_rx.recv_timeout(LOCK_WAIT);
Ok(())
})
});
entered_rx
.recv_timeout(LOCK_WAIT)
.expect("the worker must acquire the lock");
assert!(
observer.lock_is_held(),
"a separately constructed store must see the held lock"
);
release_tx.send(()).unwrap();
worker.join().unwrap().unwrap();
assert!(
!observer.lock_is_held(),
"the lock is released when the operation ends"
);
}
#[test]
fn a_second_store_waits_for_the_first_to_finish() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("attachments");
let first = AttachmentStore::new(dir.clone());
let second = AttachmentStore::new(dir);
let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel::<()>(1);
let (release_tx, release_rx) = std::sync::mpsc::sync_channel::<()>(1);
let holder = std::thread::spawn(move || {
first.with_lock(|_| {
entered_tx.send(()).unwrap();
let _ = release_rx.recv_timeout(LOCK_WAIT);
Ok(())
})
});
entered_rx.recv_timeout(LOCK_WAIT).unwrap();
let (acquired_tx, acquired_rx) = std::sync::mpsc::sync_channel::<()>(1);
let waiter = std::thread::spawn(move || {
second.with_lock(|store| {
acquired_tx.send(()).unwrap();
store.write_unlocked(b"second store bytes")
})
});
assert!(
acquired_rx.recv_timeout(LOCK_BLOCK_PROBE).is_err(),
"the second store must not enter the critical section while the first holds it"
);
release_tx.send(()).unwrap();
holder.join().unwrap().unwrap();
acquired_rx
.recv_timeout(LOCK_WAIT)
.expect("the second store proceeds once the lock is free");
let sha = waiter.join().unwrap().unwrap();
assert_eq!(sha, AttachmentStore::hash_bytes(b"second store bytes"));
}
#[test]
fn a_production_store_locks_beside_the_attachments_dir_not_inside_it() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("lific.db");
let store = AttachmentStore::from_db_path(&db_path);
assert_eq!(store.lock_path(), tmp.path().join(STORE_LOCK_FILE));
assert!(
!store.lock_path().starts_with(store.dir()),
"the lock must not live inside the replaceable attachments dir"
);
store.with_lock(|_| Ok(())).unwrap();
assert!(store.lock_path().is_file());
}
#[test]
fn the_store_lock_survives_the_attachments_directory_being_replaced() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("lific.db");
let live = AttachmentStore::from_db_path(&db_path);
let observer = AttachmentStore::from_db_path(&db_path);
live.write(b"before the restore").unwrap();
let replacement = tmp.path().join("staged-attachments");
std::fs::create_dir_all(&replacement).unwrap();
std::fs::remove_dir_all(live.dir()).unwrap();
std::fs::rename(&replacement, live.dir()).unwrap();
assert!(
live.lock_path().is_file(),
"the lock file is not collateral damage of the swap"
);
let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel::<()>(1);
let (release_tx, release_rx) = std::sync::mpsc::sync_channel::<()>(1);
let worker = std::thread::spawn(move || {
live.with_lock(|_| {
entered_tx.send(()).unwrap();
let _ = release_rx.recv_timeout(LOCK_WAIT);
Ok(())
})
});
entered_rx.recv_timeout(LOCK_WAIT).unwrap();
assert!(
observer.lock_is_held(),
"the same lock still coordinates after the directory was replaced"
);
release_tx.send(()).unwrap();
worker.join().unwrap().unwrap();
assert!(!observer.lock_is_held());
}
#[test]
fn a_test_store_keeps_its_lock_inside_the_directory_it_owns() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("attachments");
let store = AttachmentStore::new(dir.clone());
assert_eq!(store.lock_path(), dir.join(STORE_LOCK_FILE));
store.with_lock(|_| Ok(())).unwrap();
let siblings: Vec<String> = std::fs::read_dir(tmp.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert_eq!(
siblings,
vec!["attachments".to_string()],
"a test store must not leak files beside its directory"
);
}
#[test]
fn try_with_lock_reports_a_busy_store_instead_of_waiting() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("attachments");
let holder = AttachmentStore::new(dir.clone());
let requester = AttachmentStore::new(dir);
let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel::<()>(1);
let (release_tx, release_rx) = std::sync::mpsc::sync_channel::<()>(1);
let worker = std::thread::spawn(move || {
holder.with_lock(|_| {
entered_tx.send(()).unwrap();
let _ = release_rx.recv_timeout(LOCK_WAIT);
Ok(())
})
});
entered_rx.recv_timeout(LOCK_WAIT).unwrap();
let busy = requester.try_with_lock(|_| Ok(())).unwrap();
assert!(busy.is_none(), "a busy store must not block the caller");
assert!(
requester
.try_with_string_lock(|_| Ok(()))
.unwrap()
.is_none(),
"the MCP string-error path must not block either"
);
assert!(matches!(
AttachmentStore::busy_error(),
LificError::Unavailable(_)
));
release_tx.send(()).unwrap();
worker.join().unwrap().unwrap();
assert_eq!(
requester.try_with_lock(|_| Ok(7)).unwrap(),
Some(7),
"and must proceed once the store is free"
);
assert_eq!(requester.try_with_string_lock(|_| Ok(8)).unwrap(), Some(8));
}
#[test]
fn try_with_lock_also_declines_when_the_in_process_mutex_is_held() {
let (store, _tmp) = tmp_store();
let clone = store.clone();
store
.with_lock(|_| {
assert!(clone.try_with_lock(|_| Ok(())).unwrap().is_none());
Ok(())
})
.unwrap();
assert!(store.try_with_lock(|_| Ok(())).unwrap().is_some());
}
#[cfg(windows)]
#[test]
fn windows_lock_violation_is_a_busy_store() {
assert!(lock_is_busy(&std::io::Error::from_raw_os_error(33)));
}
#[cfg(unix)]
#[test]
fn the_store_lock_file_is_owner_only_and_not_a_blob() {
use std::os::unix::fs::PermissionsExt;
let (store, _tmp) = tmp_store();
store.with_lock(|_| Ok(())).unwrap();
let lock = store.lock_path();
assert!(lock.is_file());
let mode = std::fs::metadata(lock).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the lock file must be owner-only");
let name = lock.file_name().unwrap().to_string_lossy().to_string();
assert!(
!valid_sha256(&name),
"the lock file must never look like a content address: {name}"
);
}
#[test]
fn write_read_roundtrip_and_dedup() {
let (store, _tmp) = tmp_store();
let bytes = b"hello attachment world";
let sha1 = store.write(bytes).unwrap();
let sha2 = store.write(bytes).unwrap();
assert_eq!(sha1, sha2, "same content hashes to same file");
assert_eq!(store.read(&sha1).unwrap(), bytes);
let blobs: Vec<String> = std::fs::read_dir(store.dir())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
.filter(|name| valid_sha256(name))
.collect();
assert_eq!(blobs, vec![sha1]);
}
#[cfg(unix)]
#[test]
fn attachment_storage_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let (store, _tmp) = tmp_store();
let sha = store.write(b"private attachment").unwrap();
let dir_mode = std::fs::metadata(store.dir()).unwrap().permissions().mode() & 0o777;
let file_mode = std::fs::metadata(store.dir().join(sha))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
}
#[cfg(unix)]
#[test]
fn attachment_write_rejects_existing_symlink() {
use std::os::unix::fs::symlink;
let (store, _tmp) = tmp_store();
let bytes = b"private attachment";
let sha = AttachmentStore::hash_bytes(bytes);
std::fs::create_dir_all(store.dir()).unwrap();
let target = store.dir().join("outside");
std::fs::write(&target, b"outside").unwrap();
symlink(&target, store.dir().join(&sha)).unwrap();
assert!(store.write(bytes).is_err());
}
#[cfg(unix)]
#[test]
fn thumbnail_write_rejects_existing_symlink() {
use std::os::unix::fs::symlink;
let (store, _tmp) = tmp_store();
let sha = "a".repeat(64);
let thumbnail = store.thumb_path_for(&sha).unwrap();
std::fs::create_dir_all(thumbnail.parent().unwrap()).unwrap();
let target = store.dir().join("outside.webp");
std::fs::write(&target, b"outside").unwrap();
symlink(&target, &thumbnail).unwrap();
assert!(store.write_thumb(&sha, b"thumbnail").is_err());
}
#[test]
fn delete_is_idempotent() {
let (store, _tmp) = tmp_store();
let sha = store.write(b"x").unwrap();
store.delete(&sha).unwrap();
store.delete(&sha).unwrap(); assert!(store.read(&sha).is_err());
}
#[test]
fn invalid_hashes_cannot_escape_the_store() {
let (store, tmp) = tmp_store();
let outside = tmp.path().join("outside");
std::fs::write(&outside, b"must survive").unwrap();
assert!(store.read("../outside").is_err());
assert!(store.delete("../outside").is_err());
assert!(store.read_thumb("../outside").is_err());
assert!(store.delete_thumb("../outside").is_err());
assert_eq!(std::fs::read(outside).unwrap(), b"must survive");
}
#[test]
fn from_db_path_puts_attachments_next_to_db() {
let store = AttachmentStore::from_db_path(Path::new("/data/lific/lific.db"));
assert_eq!(store.dir(), Path::new("/data/lific/attachments"));
}
#[test]
fn hash_is_stable_lowercase_hex() {
assert_eq!(
AttachmentStore::hash_bytes(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn backfill_indexes_text_uploads_and_is_idempotent() {
use crate::db::queries::attachments as q;
let (store, _tmp) = tmp_store();
let pool = crate::db::open_memory().expect("test db");
let body = b"thread panicked at gribblenaut::render";
let sha = store.write(body).unwrap();
let text_id = {
let conn = pool.write().unwrap();
q::create_attachment(
&conn,
&sha,
"server.log",
"text/plain",
body.len() as i64,
None,
)
.unwrap()
.id
};
{
let conn = pool.write().unwrap();
let sha = AttachmentStore::hash_bytes(b"no-such-blob");
q::create_attachment(&conn, &sha, "ghost.log", "text/plain", 10, None).unwrap();
}
assert_eq!(backfill_attachment_text(&pool, &store).unwrap(), 1);
let indexed: String = {
let conn = pool.read().unwrap();
conn.query_row(
"SELECT extracted_text FROM attachments_fts WHERE attachment_id = ?1",
rusqlite::params![text_id],
|row| row.get(0),
)
.unwrap()
};
assert_eq!(indexed, String::from_utf8_lossy(body));
assert_eq!(
backfill_attachment_text(&pool, &store).unwrap(),
0,
"a second pass has nothing left to index"
);
}
#[test]
fn path_rejects_traversal_and_noncanonical_hashes() {
let (store, dir) = tmp_store();
for value in ["../lific.toml", &"A".repeat(64)] {
assert!(store.read(value).is_err());
assert!(store.delete(value).is_err());
}
let valid = "a".repeat(64);
assert!(store.read(&valid).is_err());
assert!(store.delete(&valid).is_ok());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sniff_png_by_signature_ignores_lying_header() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
assert_eq!(
sniff_and_validate(&png, Some("application/x-msdownload")).unwrap(),
"image/png"
);
}
#[test]
fn sniff_jpeg_gif_webp_pdf_zip() {
assert_eq!(
sniff_and_validate(&[0xFF, 0xD8, 0xFF, 0], None).unwrap(),
"image/jpeg"
);
assert_eq!(
sniff_and_validate(b"GIF89a....", None).unwrap(),
"image/gif"
);
let mut webp = Vec::from(*b"RIFF____WEBPVP8 ");
webp.extend_from_slice(&[0; 4]);
assert_eq!(sniff_and_validate(&webp, None).unwrap(), "image/webp");
assert_eq!(
sniff_and_validate(b"%PDF-1.7\n%...", None).unwrap(),
"application/pdf"
);
assert_eq!(
sniff_and_validate(&[0x50, 0x4B, 0x03, 0x04, 0], None).unwrap(),
"application/zip"
);
}
#[test]
fn rejects_elf_and_pe_executables() {
assert!(sniff_and_validate(b"\x7FELF....", Some("text/plain")).is_err());
assert!(sniff_and_validate(b"MZ\x90\x00", Some("text/plain")).is_err());
assert!(sniff_and_validate(b"#!/bin/sh\n", Some("text/plain")).is_err());
}
#[test]
fn plain_text_accepted_via_declared_type() {
assert_eq!(
sniff_and_validate(b"just some log lines\n", Some("text/plain")).unwrap(),
"text/plain"
);
}
#[test]
fn svg_accepted_when_declared_and_looks_like_svg() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#;
assert_eq!(
sniff_and_validate(svg, Some("image/svg+xml")).unwrap(),
"image/svg+xml"
);
}
#[test]
fn only_raster_images_are_inline_safe() {
assert!(is_inline_safe_mime("image/png"));
assert!(is_inline_safe_mime("image/jpeg"));
assert!(is_inline_safe_mime("image/gif"));
assert!(is_inline_safe_mime("image/webp"));
assert!(!is_inline_safe_mime("application/pdf"));
assert!(!is_inline_safe_mime("text/plain"));
}
#[test]
fn svg_is_never_inline_safe() {
assert!(!is_inline_safe_mime("image/svg+xml"));
}
#[test]
fn inline_safe_types_are_all_allowed_uploads() {
for mime in ALLOWED_MIMES {
if is_inline_safe_mime(mime) {
assert!(ALLOWED_MIMES.contains(mime));
}
}
assert!(ALLOWED_MIMES.contains(&"image/svg+xml"));
}
struct SyntheticReader {
byte: u8,
remaining: u64,
largest_request: usize,
served: u64,
}
impl SyntheticReader {
fn new(byte: u8, total: u64) -> Self {
Self {
byte,
remaining: total,
largest_request: 0,
served: 0,
}
}
}
impl Read for SyntheticReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.largest_request = self.largest_request.max(buf.len());
if self.remaining == 0 {
return Ok(0);
}
let take = buf.len().min(self.remaining as usize);
buf[..take].fill(self.byte);
self.remaining -= take as u64;
self.served += take as u64;
Ok(take)
}
}
#[test]
fn streamed_sniffing_agrees_with_the_in_memory_sniffer() {
let svg = b"<svg xmlns='http://www.w3.org/2000/svg'></svg>".to_vec();
let cases: Vec<(Vec<u8>, Option<&str>)> = vec![
(png_image(2, 2), None),
(png_image(2, 2), Some("application/x-msdownload")),
(mp4_bytes(), None),
(webm_bytes(), Some("audio/webm")),
(b"%PDF-1.7\n%...".to_vec(), None),
(b"just some log lines\n".to_vec(), Some("text/plain")),
(b"plain text with no declared type".to_vec(), None),
(svg, Some("image/svg+xml")),
(b"\x7FELF....".to_vec(), Some("text/plain")),
(b"#!/bin/sh\n".to_vec(), None),
(vec![0xFF, 0xFE, 0x00], Some("text/plain")),
(Vec::new(), None),
];
for (bytes, declared) in cases {
let in_memory = sniff_and_validate(&bytes, declared);
let streamed = sniff_and_validate_stream(bytes.as_slice(), declared);
match (&in_memory, &streamed) {
(Ok(a), Ok(b)) => assert_eq!(a, b, "disagreement on {declared:?} {bytes:?}"),
(Err(_), Err(_)) => {}
_ => panic!(
"streamed and in-memory sniffing disagree: {in_memory:?} vs {streamed:?}"
),
}
}
}
#[test]
fn streamed_sniffing_reads_a_huge_text_attachment_in_bounded_chunks() {
const TOTAL: u64 = 512 * 1024 * 1024;
let mut reader = SyntheticReader::new(b'a', TOTAL);
let mime = sniff_and_validate_stream(&mut reader, Some("text/plain")).unwrap();
assert_eq!(mime, "text/plain");
assert_eq!(reader.served, TOTAL, "every byte must be validated");
assert!(
reader.largest_request <= UTF8_STREAM_CHUNK.max(SNIFF_PREFIX_BYTES),
"the consumer asked for a {}-byte buffer; validation must stay bounded",
reader.largest_request
);
}
struct ExplodingTail {
head: std::io::Cursor<Vec<u8>>,
}
impl Read for ExplodingTail {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self.head.read(buf)? {
0 => Err(std::io::Error::other("read past the sniff prefix")),
n => Ok(n),
}
}
}
#[test]
fn streamed_sniffing_stops_at_the_prefix_once_a_signature_decides_it() {
let mut png = png_image(2, 2);
png.resize(SNIFF_PREFIX_BYTES, 0);
let reader = ExplodingTail {
head: std::io::Cursor::new(png),
};
assert_eq!(
sniff_and_validate_stream(reader, None).unwrap(),
"image/png",
"a signature settles the type without reading the rest of the file"
);
}
#[test]
fn streamed_utf8_validation_handles_characters_split_across_chunks() {
let mut text = vec![b'a'; SNIFF_PREFIX_BYTES - 1];
text.extend_from_slice("€ tail".as_bytes());
assert_eq!(
sniff_and_validate_stream(text.as_slice(), Some("text/plain")).unwrap(),
"text/plain"
);
assert_eq!(
sniff_and_validate(&text, Some("text/plain")).unwrap(),
"text/plain"
);
let mut truncated = vec![b'a'; SNIFF_PREFIX_BYTES + 16];
truncated.extend_from_slice(&"€".as_bytes()[..2]);
assert!(
sniff_and_validate_stream(truncated.as_slice(), Some("text/plain")).is_err(),
"a file ending mid-character is not valid text"
);
assert!(sniff_and_validate(&truncated, Some("text/plain")).is_err());
let mut binary = vec![b'a'; SNIFF_PREFIX_BYTES + 8];
binary.extend_from_slice(&[0xC3, 0x28]);
assert!(sniff_and_validate_stream(binary.as_slice(), Some("text/plain")).is_err());
}
fn mp4_bytes() -> Vec<u8> {
let mut v = vec![0, 0, 0, 0x20];
v.extend_from_slice(b"ftypisom");
v.extend_from_slice(b"\0\0\x02\0isomiso2avc1mp41");
v
}
fn webm_bytes() -> Vec<u8> {
let mut v = vec![0x1A, 0x45, 0xDF, 0xA3];
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F]);
v.extend_from_slice(b"\x42\x82\x84webm");
v.extend_from_slice(&[0; 16]);
v
}
#[test]
fn sniffs_every_new_media_container() {
assert_eq!(sniff_and_validate(&mp4_bytes(), None).unwrap(), "video/mp4");
assert_eq!(
sniff_and_validate(&webm_bytes(), None).unwrap(),
"video/webm"
);
let mut ogg = Vec::from(*b"OggS");
ogg.extend_from_slice(&[0; 32]);
assert_eq!(sniff_and_validate(&ogg, None).unwrap(), "audio/ogg");
let mut id3 = Vec::from(*b"ID3\x03\x00\x00");
id3.extend_from_slice(&[0; 32]);
assert_eq!(sniff_and_validate(&id3, None).unwrap(), "audio/mpeg");
assert_eq!(
sniff_and_validate(&[0xFF, 0xFB, 0x90, 0x00, 0, 0, 0, 0], None).unwrap(),
"audio/mpeg"
);
}
#[test]
fn webm_declared_as_audio_is_recorded_as_audio() {
assert_eq!(
sniff_and_validate(&webm_bytes(), Some("audio/webm")).unwrap(),
"audio/webm"
);
assert_eq!(
sniff_and_validate(&png_image(2, 2), Some("video/mp4")).unwrap(),
"image/png"
);
}
#[test]
fn quicktime_is_not_relabelled_as_mp4() {
let mut mov = vec![0, 0, 0, 0x14];
mov.extend_from_slice(b"ftypqt ");
mov.extend_from_slice(&[0xFF; 16]);
assert!(sniff_and_validate(&mov, Some("video/mp4")).is_err());
assert_eq!(sniff_magic(&mov), None);
}
#[test]
fn reserved_mpeg_header_fields_are_not_audio() {
let reserved_version = [0xFF, 0xEA, 0x00, 0x00, 0xFF, 0xFE];
assert_ne!(sniff_magic(&reserved_version), Some("audio/mpeg"));
let reserved_layer = [0xFF, 0xF9, 0x00, 0x00, 0xFF, 0xFE];
assert_ne!(sniff_magic(&reserved_layer), Some("audio/mpeg"));
}
#[test]
fn sqlite_databases_are_allowed_and_never_inline() {
let mut db = SQLITE_MAGIC.to_vec();
db.extend_from_slice(&[0; 64]);
assert_eq!(
sniff_and_validate(&db, None).unwrap(),
"application/vnd.sqlite3"
);
assert!(ALLOWED_MIMES.contains(&"application/vnd.sqlite3"));
assert!(!is_inline_safe_mime("application/vnd.sqlite3"));
}
#[test]
fn media_types_serve_inline() {
for mime in [
"video/mp4",
"video/webm",
"audio/webm",
"audio/ogg",
"audio/mpeg",
] {
assert!(is_inline_safe_mime(mime), "{mime} must serve inline");
assert!(ALLOWED_MIMES.contains(&mime), "{mime} must be uploadable");
}
}
#[test]
fn decodes_raster_dimensions() {
assert_eq!(image_dimensions(&png_image(37, 11)), Some((37, 11)));
assert_eq!(image_dimensions(b"not an image at all"), None);
}
#[test]
fn raster_mimes_are_the_decodable_ones() {
for mime in ["image/png", "image/jpeg", "image/gif", "image/webp"] {
assert!(is_raster_mime(mime));
}
assert!(!is_raster_mime("image/svg+xml"));
assert!(!is_raster_mime("video/mp4"));
}
#[test]
fn thumbnail_fits_the_long_edge_and_preserves_aspect() {
let thumb = generate_thumbnail(&png_image(1200, 300))
.unwrap()
.expect("an oversize image gets a thumbnail");
let (w, h) = image_dimensions(&thumb).expect("thumbnail decodes");
assert_eq!((w, h), (480, 120));
assert!(thumb.starts_with(b"RIFF"));
assert_eq!(&thumb[8..12], b"WEBP");
}
#[test]
fn small_images_get_no_thumbnail() {
assert!(generate_thumbnail(&png_image(480, 100)).unwrap().is_none());
assert!(generate_thumbnail(&png_image(64, 64)).unwrap().is_none());
assert!(generate_thumbnail(b"not an image").is_err());
}
#[test]
fn expects_thumbnail_tracks_mime_and_long_edge() {
assert!(expects_thumbnail("image/png", Some(1000), Some(10)));
assert!(expects_thumbnail("image/jpeg", Some(10), Some(1000)));
assert!(!expects_thumbnail("image/png", Some(480), Some(480)));
assert!(!expects_thumbnail("image/png", None, None));
assert!(!expects_thumbnail("video/mp4", Some(1920), Some(1080)));
assert!(!expects_thumbnail(
"application/pdf",
Some(1920),
Some(1080)
));
}
#[test]
fn thumbnails_round_trip_and_live_in_a_subdirectory() {
let (store, _tmp) = tmp_store();
let sha = store.write(&png_image(600, 600)).unwrap();
assert!(store.read_thumb(&sha).unwrap().is_none());
store.write_thumb(&sha, b"pretend webp").unwrap();
assert_eq!(store.read_thumb(&sha).unwrap().unwrap(), b"pretend webp");
assert_eq!(
store.thumb_path_for(&sha).unwrap().parent().unwrap(),
store.dir().join("thumbs")
);
store.delete(&sha).unwrap();
assert!(store.read_thumb(&sha).unwrap().is_none());
store.delete_thumb(&sha).unwrap();
}
}