synops/tools/synops-common/src/cas.rs
vegard 6496434bd3 synops-common: delt lib for alle CLI-verktøy (oppgave 21.16)
Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:

- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs

Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
2026-03-18 10:51:40 +00:00

127 lines
3.6 KiB
Rust

//! 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<String, String> {
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<String, String> {
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");
}
}