//! Content-Addressable Store (CAS) utilities. //! //! CAS lagrer filer med SHA-256-hash som nøkkel, organisert i //! en to-nivå katalogstruktur: `{root}/{hash[0..2]}/{hash[2..4]}/{hash}`. //! //! Miljøvariabel: CAS_ROOT (default: /srv/synops/media/cas) use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// Standard CAS-rotkatalog. pub const DEFAULT_CAS_ROOT: &str = "/srv/synops/media/cas"; /// Hent CAS-rotkatalog fra miljøvariabel eller bruk default. pub fn root() -> String { std::env::var("CAS_ROOT").unwrap_or_else(|_| DEFAULT_CAS_ROOT.into()) } /// Beregn filsti i CAS fra rot og hash. /// /// Struktur: `{root}/{hash[0..2]}/{hash[2..4]}/{hash}` /// /// # Eksempel /// ``` /// use synops_common::cas; /// let p = cas::path("/srv/synops/media/cas", "b94d27b9934d3e08"); /// assert_eq!(p.to_str().unwrap(), "/srv/synops/media/cas/b9/4d/b94d27b9934d3e08"); /// ``` pub fn path(root: &str, hash: &str) -> PathBuf { let (p1, rest) = hash.split_at(2.min(hash.len())); let (p2, _) = rest.split_at(2.min(rest.len())); PathBuf::from(root).join(p1).join(p2).join(hash) } /// Beregn SHA-256 hash av bytes. Returnerer hex-streng. pub fn hash_bytes(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) } /// Beregn SHA-256 hash av en fil. Returnerer hex-streng. pub async fn hash_file(file_path: &Path) -> Result { let data = tokio::fs::read(file_path) .await .map_err(|e| format!("Kunne ikke lese fil {}: {e}", file_path.display()))?; Ok(hash_bytes(&data)) } /// Lagre bytes i CAS. Returnerer hash. Oppretter kataloger ved behov. pub async fn store(cas_root: &str, data: &[u8]) -> Result { let hash = hash_bytes(data); let target = path(cas_root, &hash); if target.exists() { return Ok(hash); } if let Some(parent) = target.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("Kunne ikke opprette CAS-katalog: {e}"))?; } tokio::fs::write(&target, data) .await .map_err(|e| format!("Kunne ikke skrive CAS-fil: {e}"))?; Ok(hash) } /// Konverter MIME-type til filendelse. pub fn mime_to_extension(mime: &str) -> &str { match mime { "audio/mpeg" | "audio/mp3" => "mp3", "audio/wav" | "audio/x-wav" => "wav", "audio/ogg" => "ogg", "audio/flac" | "audio/x-flac" => "flac", "audio/mp4" | "audio/m4a" | "audio/x-m4a" => "m4a", "audio/webm" => "webm", "video/mp4" => "mp4", "video/webm" => "webm", "image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", "text/html" => "html", "application/pdf" => "pdf", _ => "bin", } } #[cfg(test)] mod tests { use super::*; #[test] fn cas_path_format() { let p = path("/srv/synops/media/cas", "b94d27b9934d3e08"); assert_eq!( p, PathBuf::from("/srv/synops/media/cas/b9/4d/b94d27b9934d3e08") ); } #[test] fn cas_path_short_hash() { let p = path("/cas", "ab"); assert_eq!(p, PathBuf::from("/cas/ab//ab")); } #[test] fn hash_known_value() { let hash = hash_bytes(b"hello world"); assert_eq!( hash, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); } #[test] fn mime_mapping() { assert_eq!(mime_to_extension("audio/mpeg"), "mp3"); assert_eq!(mime_to_extension("image/png"), "png"); assert_eq!(mime_to_extension("unknown/type"), "bin"); } }