aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat};

use super::blocking;
use super::decode::{decode_first_frame, validate_image_blocking};
use super::storage::{object_path, sha256_hex};
use super::validation::validate_sha256;

pub(crate) const PREVIEW_LONG_EDGE: u32 = 1_200;
pub(crate) const MAX_PREVIEW_PIXELS: u64 = 1_000_000;
pub(crate) const MAX_PREVIEW_BYTES: usize = 5 * 1024 * 1024;
pub(crate) const PREVIEW_PROFILE: &str = "v1-edge1200-pixels1000000-png";

pub(crate) fn cached_preview_path(blob_dir: &Path, source_hash: &str) -> Result<PathBuf> {
    validate_sha256(source_hash)?;
    Ok(blob_dir
        .join("cache")
        .join("previews")
        .join(PREVIEW_PROFILE)
        .join(format!("{source_hash}.png")))
}

#[cfg(test)]
pub(crate) async fn ensure_preview(blob_dir: &Path, source_hash: &str) -> Result<PathBuf> {
    let blob_dir = blob_dir.to_path_buf();
    let source_hash = source_hash.to_string();
    blocking::run_preview(move || ensure_preview_blocking(&blob_dir, &source_hash)).await
}

pub(crate) async fn load_preview_png(
    blob_dir: &Path,
    source_hash: &str,
    preview_quota_bytes: u64,
) -> Result<Vec<u8>> {
    let blob_dir = blob_dir.to_path_buf();
    let source_hash = source_hash.to_string();
    blocking::run_preview(move || {
        let path = ensure_preview_blocking(&blob_dir, &source_hash)?;
        let bytes = read_validated_preview(&path)?;
        crate::attachments::lifecycle::prune_preview_cache(&blob_dir, preview_quota_bytes)?;
        Ok(bytes)
    })
    .await
}

fn ensure_preview_blocking(blob_dir: &Path, source_hash: &str) -> Result<PathBuf> {
    let preview = cached_preview_path(blob_dir, source_hash)?;
    if preview.exists() && validate_cached_preview(&preview).is_ok() {
        return Ok(preview);
    }

    let source = object_path(blob_dir, source_hash)?;
    let source_bytes = fs::read(&source).context("could not read attachment preview source")?;
    if sha256_hex(&source_bytes) != source_hash {
        bail!("error attachment-preview-source-hash-mismatch");
    }
    let first_frame = decode_first_frame(&source_bytes)?;
    let (target_width, target_height) =
        preview_dimensions(first_frame.width(), first_frame.height());
    let resized = image::imageops::resize(
        &first_frame,
        target_width,
        target_height,
        FilterType::Lanczos3,
    );
    let mut encoded = Cursor::new(Vec::new());
    DynamicImage::ImageRgba8(resized).write_to(&mut encoded, ImageFormat::Png)?;
    let encoded = encoded.into_inner();
    if encoded.len() > MAX_PREVIEW_BYTES {
        bail!("error attachment-preview-byte-limit");
    }
    let validated = validate_image_blocking(encoded, Some("image/png"))?;
    if u64::try_from(validated.facts.width).unwrap_or(u64::MAX)
        * u64::try_from(validated.facts.height).unwrap_or(u64::MAX)
        > MAX_PREVIEW_PIXELS
    {
        bail!("error attachment-preview-pixel-limit");
    }

    let parent = preview
        .parent()
        .ok_or_else(|| anyhow::anyhow!("preview path has no parent"))?;
    fs::create_dir_all(parent).context("could not create attachment preview directory")?;
    let mut temp = tempfile::NamedTempFile::new_in(parent)
        .context("could not create attachment preview file")?;
    std::io::Write::write_all(&mut temp, &validated.bytes)?;
    temp.as_file().sync_all()?;
    temp.persist(&preview)
        .map_err(|error| error.error)
        .context("could not replace attachment preview")?;
    Ok(preview)
}

fn validate_cached_preview(path: &Path) -> Result<()> {
    read_validated_preview(path).map(|_| ())
}

fn read_validated_preview(path: &Path) -> Result<Vec<u8>> {
    let bytes = fs::read(path)?;
    if bytes.len() > MAX_PREVIEW_BYTES {
        bail!("error attachment-preview-byte-limit");
    }
    let validated = validate_image_blocking(bytes, Some("image/png"))?;
    let width = u64::try_from(validated.facts.width).unwrap_or(u64::MAX);
    let height = u64::try_from(validated.facts.height).unwrap_or(u64::MAX);
    if width > u64::from(PREVIEW_LONG_EDGE)
        || height > u64::from(PREVIEW_LONG_EDGE)
        || width.saturating_mul(height) > MAX_PREVIEW_PIXELS
    {
        bail!("error attachment-preview-dimension-limit");
    }
    Ok(validated.bytes)
}

fn preview_dimensions(width: u32, height: u32) -> (u32, u32) {
    let long_scale = f64::from(PREVIEW_LONG_EDGE) / f64::from(width.max(height));
    let pixel_scale = (MAX_PREVIEW_PIXELS as f64 / (f64::from(width) * f64::from(height))).sqrt();
    let scale = long_scale.min(pixel_scale).min(1.0);
    let mut target_width = (f64::from(width) * scale).floor().max(1.0) as u32;
    let mut target_height = (f64::from(height) * scale).floor().max(1.0) as u32;
    while u64::from(target_width) * u64::from(target_height) > MAX_PREVIEW_PIXELS {
        if target_width >= target_height {
            target_width -= 1;
        } else {
            target_height -= 1;
        }
    }
    (target_width, target_height)
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;

    use image::{DynamicImage, ImageFormat, RgbaImage};

    use super::*;

    fn png(width: u32, height: u32) -> Vec<u8> {
        let mut bytes = Cursor::new(Vec::new());
        DynamicImage::ImageRgba8(RgbaImage::new(width, height))
            .write_to(&mut bytes, ImageFormat::Png)
            .unwrap();
        bytes.into_inner()
    }

    #[tokio::test]
    async fn creates_repeatable_disposable_preview() {
        let temp = tempfile::tempdir().unwrap();
        let bytes = png(2_000, 1_000);
        let hash = sha256_hex(&bytes);
        let source = object_path(temp.path(), &hash).unwrap();
        fs::create_dir_all(source.parent().unwrap()).unwrap();
        fs::write(&source, bytes).unwrap();

        let (first, concurrent) = tokio::join!(
            ensure_preview(temp.path(), &hash),
            ensure_preview(temp.path(), &hash)
        );
        let first = first.unwrap();
        assert_eq!(first, concurrent.unwrap());
        let first_bytes = fs::read(&first).unwrap();
        let loaded = load_preview_png(temp.path(), &hash, u64::MAX)
            .await
            .unwrap();
        assert_eq!(loaded, first_bytes);
        assert_ne!(loaded, fs::read(&source).unwrap());
        let decoded = image::load_from_memory_with_format(&loaded, ImageFormat::Png).unwrap();
        assert_eq!((decoded.width(), decoded.height()), (1_200, 600));
        let second = ensure_preview(temp.path(), &hash).await.unwrap();
        assert_eq!(first, second);
        assert_eq!(first_bytes, fs::read(second).unwrap());

        fs::write(&first, b"partial preview").unwrap();
        let regenerated = ensure_preview(temp.path(), &hash).await.unwrap();
        assert_eq!(first_bytes, fs::read(regenerated).unwrap());
    }

    #[tokio::test]
    async fn materialization_enforces_preview_cache_quota() {
        let temp = tempfile::tempdir().unwrap();
        let bytes = png(2_000, 1_000);
        let hash = sha256_hex(&bytes);
        let source = object_path(temp.path(), &hash).unwrap();
        fs::create_dir_all(source.parent().unwrap()).unwrap();
        fs::write(source, bytes).unwrap();

        let loaded = load_preview_png(temp.path(), &hash, 0).await.unwrap();
        assert!(!loaded.is_empty());
        assert!(!cached_preview_path(temp.path(), &hash).unwrap().exists());
    }

    #[tokio::test]
    async fn missing_source_fails_without_creating_cache_entry() {
        let temp = tempfile::tempdir().unwrap();
        let hash = "0".repeat(64);

        assert!(
            load_preview_png(temp.path(), &hash, u64::MAX)
                .await
                .is_err()
        );
        assert!(!cached_preview_path(temp.path(), &hash).unwrap().exists());
    }

    #[test]
    fn bounds_preview_dimensions() {
        assert_eq!(preview_dimensions(2_000, 1_000), (1_200, 600));
        let (width, height) = preview_dimensions(16_000, 16_000);
        assert!(u64::from(width) * u64::from(height) <= MAX_PREVIEW_PIXELS);
        assert!(width <= PREVIEW_LONG_EDGE && height <= PREVIEW_LONG_EDGE);
    }
}