use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::error::LificError;
#[derive(Debug, Clone)]
pub struct AttachmentStore {
dir: PathBuf,
}
impl AttachmentStore {
pub fn from_db_path(db_path: &Path) -> Self {
let dir = match db_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join("attachments"),
_ => PathBuf::from("attachments"),
};
Self { dir }
}
#[allow(dead_code)]
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
#[allow(dead_code)]
pub fn dir(&self) -> &Path {
&self.dir
}
pub(crate) fn path_for(&self, sha256: &str) -> PathBuf {
self.dir.join(sha256)
}
pub fn hash_bytes(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn write(&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}")))?;
let path = self.path_for(&sha);
if path.exists() {
return Ok(sha);
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)
.map_err(|e| LificError::Internal(format!("write attachment: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &path)
.map_err(|e| LificError::Internal(format!("finalize attachment: {e}")))?;
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}"))
}
})
}
pub fn delete(&self, sha256: &str) -> Result<(), LificError> {
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 conn = pool.write()?;
q::delete_attachment(&conn, orphan.id)?;
}
let remaining = {
let conn = pool.read()?;
q::count_rows_for_sha(&conn, &orphan.sha256)?
};
if remaining == 0 {
store.delete(&orphan.sha256)?;
}
collected += 1;
}
Ok(collected)
}
pub fn start_gc_task(
pool: crate::db::DbPool,
store: AttachmentStore,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
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;
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"),
}
}
})
}
pub const ALLOWED_MIMES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"text/plain",
"application/zip",
];
pub fn is_image_mime(mime: &str) -> bool {
mime.starts_with("image/")
}
pub fn sniff_and_validate(bytes: &[u8], declared: Option<&str>) -> Result<String, LificError> {
if let Some(mime) = sniff_magic(bytes) {
return Ok(mime.to_string());
}
if looks_executable(bytes) {
return Err(LificError::BadRequest(
"rejected: file looks like an executable".into(),
));
}
let declared = declared.map(|d| d.split(';').next().unwrap_or(d).trim().to_ascii_lowercase());
if let Some(d) = declared.as_deref() {
if d == "image/svg+xml" && looks_like_svg(bytes) {
return Ok("image/svg+xml".to_string());
}
if (d == "text/plain" || d == "text/x-log") && std::str::from_utf8(bytes).is_ok() {
return Ok("text/plain".to_string());
}
}
if std::str::from_utf8(bytes).is_ok() && !bytes.is_empty() {
return Ok("text/plain".to_string());
}
Err(LificError::BadRequest(
"rejected: unsupported or unrecognized file type".into(),
))
}
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");
}
None
}
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))
}
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)]
mod tests {
use super::*;
fn tmp_store() -> (AttachmentStore, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"lific_store_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
(AttachmentStore::new(dir.clone()), dir)
}
#[test]
fn write_read_roundtrip_and_dedup() {
let (store, dir) = 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 count = std::fs::read_dir(&dir).unwrap().count();
assert_eq!(count, 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn delete_is_idempotent() {
let (store, dir) = tmp_store();
let sha = store.write(b"x").unwrap();
store.delete(&sha).unwrap();
store.delete(&sha).unwrap(); assert!(store.read(&sha).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[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 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 is_image_mime_classifies() {
assert!(is_image_mime("image/png"));
assert!(is_image_mime("image/svg+xml"));
assert!(!is_image_mime("application/pdf"));
assert!(!is_image_mime("text/plain"));
}
}