im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use std::path::Path;

use base64::Engine;
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PreparedAttachmentPayload {
    pub filename: String,
    pub mime_type: String,
    pub size_bytes: u64,
    pub size_string: String,
    pub digest_b64u: String,
    pub payload: Vec<u8>,
}

pub(crate) fn prepare_attachment_payload_from_path(
    path: &Path,
    mime_override: &str,
    payload: Vec<u8>,
) -> crate::ImResult<PreparedAttachmentPayload> {
    let filename = filename_from_path(path)?;
    prepare_attachment_payload(&filename, mime_override, payload)
}

pub(crate) fn prepare_attachment_payload(
    filename: &str,
    mime_override: &str,
    payload: Vec<u8>,
) -> crate::ImResult<PreparedAttachmentPayload> {
    let filename = filename.trim();
    if filename.is_empty() {
        return Err(crate::ImError::invalid_input(
            Some("filename".to_string()),
            "attachment filename is required",
        ));
    }
    let mime_type = if mime_override.trim().is_empty() {
        detect_attachment_mime_type(filename, &payload)
    } else {
        mime_override.trim().to_string()
    };
    let size_bytes = payload.len() as u64;
    Ok(PreparedAttachmentPayload {
        filename: filename.to_string(),
        mime_type,
        size_bytes,
        size_string: size_bytes.to_string(),
        digest_b64u: sha256_digest_b64u(&payload),
        payload,
    })
}

pub(crate) async fn prepare_attachment_metadata_from_path(
    path: &Path,
    mime_override: &str,
) -> crate::ImResult<PreparedAttachmentPayload> {
    let filename = filename_from_path(path)?;
    let mime_type = if mime_override.trim().is_empty() {
        detect_attachment_mime_type_from_path(&filename, path).await?
    } else {
        mime_override.trim().to_string()
    };
    let metadata = tokio::fs::metadata(path)
        .await
        .map_err(|err| crate::ImError::Io {
            detail: format!("read attachment metadata {}: {err}", path.display()),
        })?;
    if metadata.is_dir() {
        return Err(crate::ImError::invalid_input(
            Some("file_path".to_string()),
            format!("attachment file path is a directory: {}", path.display()),
        ));
    }
    let digest_b64u = sha256_digest_file_b64u(path).await?;
    let size_bytes = metadata.len();
    Ok(PreparedAttachmentPayload {
        filename,
        mime_type,
        size_bytes,
        size_string: size_bytes.to_string(),
        digest_b64u,
        payload: Vec::new(),
    })
}

pub(crate) fn filename_from_path(path: &Path) -> crate::ImResult<String> {
    let filename = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or_default();
    if filename.is_empty() {
        return Err(crate::ImError::invalid_input(
            Some("file_path".to_string()),
            format!("attachment filename could not be derived from {path:?}"),
        ));
    }
    Ok(filename.to_string())
}

pub(crate) fn sha256_digest_b64u(payload: &[u8]) -> String {
    let digest = Sha256::digest(payload);
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}

pub(crate) async fn sha256_digest_file_b64u(path: &Path) -> crate::ImResult<String> {
    let mut file = tokio::fs::File::open(path)
        .await
        .map_err(|err| crate::ImError::Io {
            detail: format!("open attachment file {}: {err}", path.display()),
        })?;
    let mut hasher = Sha256::new();
    let mut buffer = [0_u8; 8192];
    loop {
        let count = file
            .read(&mut buffer)
            .await
            .map_err(|err| crate::ImError::Io {
                detail: format!("read attachment file {}: {err}", path.display()),
            })?;
        if count == 0 {
            break;
        }
        hasher.update(&buffer[..count]);
    }
    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize()))
}

pub(crate) fn detect_attachment_mime_type(filename: &str, payload: &[u8]) -> String {
    let ext = Path::new(filename)
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    if let Some(mime) = mime_type_by_extension(&ext) {
        return mime.to_string();
    }
    detect_attachment_content_type(payload)
}

async fn detect_attachment_mime_type_from_path(
    filename: &str,
    path: &Path,
) -> crate::ImResult<String> {
    let ext = Path::new(filename)
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    if let Some(mime) = mime_type_by_extension(&ext) {
        return Ok(mime.to_string());
    }
    let mut file = tokio::fs::File::open(path)
        .await
        .map_err(|err| crate::ImError::Io {
            detail: format!("open attachment file {}: {err}", path.display()),
        })?;
    let mut sample = vec![0_u8; 512];
    let count = file
        .read(&mut sample)
        .await
        .map_err(|err| crate::ImError::Io {
            detail: format!("read attachment file {}: {err}", path.display()),
        })?;
    sample.truncate(count);
    Ok(detect_attachment_content_type(&sample))
}

fn mime_type_by_extension(ext: &str) -> Option<&'static str> {
    match ext {
        "txt" | "text" | "log" | "md" | "csv" => Some("text/plain; charset=utf-8"),
        "json" => Some("application/json"),
        "html" | "htm" => Some("text/html; charset=utf-8"),
        "css" => Some("text/css; charset=utf-8"),
        "js" | "mjs" => Some("text/javascript; charset=utf-8"),
        "png" => Some("image/png"),
        "jpg" | "jpeg" => Some("image/jpeg"),
        "gif" => Some("image/gif"),
        "webp" => Some("image/webp"),
        "pdf" => Some("application/pdf"),
        "xml" => Some("text/xml; charset=utf-8"),
        "zip" => Some("application/zip"),
        "gz" => Some("application/gzip"),
        "tar" => Some("application/x-tar"),
        "wasm" => Some("application/wasm"),
        _ => None,
    }
}

fn detect_attachment_content_type(payload: &[u8]) -> String {
    if payload.is_empty() {
        return "application/octet-stream".to_string();
    }
    let sample = &payload[..payload.len().min(512)];
    if sample.starts_with(b"\x89PNG\r\n\x1a\n") {
        return "image/png".to_string();
    }
    if sample.starts_with(b"\xff\xd8\xff") {
        return "image/jpeg".to_string();
    }
    if sample.starts_with(b"GIF87a") || sample.starts_with(b"GIF89a") {
        return "image/gif".to_string();
    }
    if sample.starts_with(b"%PDF-") {
        return "application/pdf".to_string();
    }
    if sample.starts_with(b"PK\x03\x04") {
        return "application/zip".to_string();
    }
    if sample
        .iter()
        .all(|byte| matches!(*byte, b'\t' | b'\n' | b'\r' | 0x20..=0x7e))
    {
        return "text/plain; charset=utf-8".to_string();
    }
    "application/octet-stream".to_string()
}