use blake3;
use bytes::Bytes;
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
use object_store::local::LocalFileSystem;
use object_store::path::Path as StorePath;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use thiserror::Error;
use tracing::{debug, error, info};
#[derive(Error, Debug)]
pub enum StorageError {
#[error("storage initialisation failed: {0}")]
Init(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("object store error: {0}")]
ObjectStore(#[from] object_store::Error),
#[error("invalid content hash: {0}")]
InvalidHash(String),
#[error("required environment variable `{0}` is not set")]
MissingEnvVar(String),
#[error("invalid storage path: {0}")]
InvalidPath(String),
}
pub type StorageResult<T> = Result<T, StorageError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageBackend {
Local,
S3,
}
impl std::fmt::Display for StorageBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StorageBackend::Local => write!(f, "local"),
StorageBackend::S3 => write!(f, "s3"),
}
}
}
#[derive(Debug, Clone)]
pub struct StorageConfig {
pub backend: StorageBackend,
pub base_dir: PathBuf,
pub assets_prefix: String,
pub bucket: String,
}
pub struct StorageEngine {
store: Box<dyn ObjectStore>,
config: StorageConfig,
}
impl StorageEngine {
pub fn from_env() -> StorageResult<Self> {
let backend_str =
std::env::var("NAP_STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
match backend_str.to_lowercase().as_str() {
"local" => Self::init_local(),
"s3" => Self::init_s3(),
other => Err(StorageError::Init(format!(
"unknown storage backend '{other}'; expected 'local' or 's3'"
))),
}
}
fn init_local() -> StorageResult<Self> {
let raw = std::env::var("NAP_DIR").unwrap_or_else(|_| "~/.nap".to_string());
let base_dir = resolve_path(&raw);
let assets_dir = base_dir.join(".nap-assets");
std::fs::create_dir_all(&assets_dir).map_err(|e| {
error!(
path = %assets_dir.display(),
error = %e,
"failed to create assets directory"
);
StorageError::Io(e)
})?;
Self::ensure_gitignore(&base_dir)?;
let store = Box::new(LocalFileSystem::new()) as Box<dyn ObjectStore>;
info!(
backend = "local",
base_dir = %base_dir.display(),
assets_dir = %assets_dir.display(),
"storage engine initialised (local)"
);
Ok(Self {
store,
config: StorageConfig {
backend: StorageBackend::Local,
base_dir,
assets_prefix: ".nap-assets".to_string(),
bucket: String::new(),
},
})
}
fn ensure_gitignore(base_dir: &Path) -> StorageResult<()> {
let gitignore_path = base_dir.join(".gitignore");
const ASSETS_ENTRY: &str = ".nap-assets/";
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, format!("{ASSETS_ENTRY}\n")).map_err(|e| {
error!(
path = %gitignore_path.display(),
error = %e,
"failed to create .gitignore"
);
StorageError::Io(e)
})?;
info!(
path = %gitignore_path.display(),
"created .gitignore with .nap-assets/ entry"
);
return Ok(());
}
let content = std::fs::read_to_string(&gitignore_path).map_err(|e| {
error!(
path = %gitignore_path.display(),
error = %e,
"failed to read existing .gitignore"
);
StorageError::Io(e)
})?;
let already_present = content.lines().any(|line| {
let trimmed = line.trim();
trimmed == ASSETS_ENTRY || trimmed.trim_end_matches('/') == ".nap-assets"
});
if !already_present {
let updated = format!("{content}\n{ASSETS_ENTRY}\n");
std::fs::write(&gitignore_path, updated).map_err(|e| {
error!(
path = %gitignore_path.display(),
error = %e,
"failed to append .nap-assets/ to .gitignore"
);
StorageError::Io(e)
})?;
info!(
path = %gitignore_path.display(),
"appended .nap-assets/ entry to .gitignore"
);
} else {
debug!(
path = %gitignore_path.display(),
".gitignore already contains .nap-assets/ entry"
);
}
Ok(())
}
fn init_s3() -> StorageResult<Self> {
let bucket = std::env::var("NAP_S3_BUCKET").map_err(|_| {
error!("NAP_S3_BUCKET is required for S3 storage backend");
StorageError::MissingEnvVar("NAP_S3_BUCKET".to_string())
})?;
let mut builder = AmazonS3Builder::from_env().with_bucket_name(&bucket);
let endpoint_var =
std::env::var("AWS_ENDPOINT_URL_S3").or_else(|_| std::env::var("AWS_ENDPOINT_URL"));
if let Ok(endpoint) = endpoint_var {
let endpoint_str = endpoint.trim().to_string();
if endpoint_str.starts_with("http://") {
builder = builder.with_allow_http(true);
debug!(endpoint = %endpoint_str, "HTTP endpoint detected, allowing HTTP");
}
builder = builder.with_endpoint(&endpoint_str);
debug!(endpoint = %endpoint_str, "configured custom S3 endpoint");
}
let store: Box<dyn ObjectStore> = Box::new(builder.build().map_err(|e| {
error!(
bucket = %bucket,
error = %e,
"failed to build S3 object store"
);
StorageError::ObjectStore(e)
})?);
info!(
backend = "s3",
bucket = %bucket,
"storage engine initialised (S3)"
);
Ok(Self {
store,
config: StorageConfig {
backend: StorageBackend::S3,
base_dir: PathBuf::new(),
assets_prefix: ".nap-assets".to_string(),
bucket,
},
})
}
pub fn config(&self) -> &StorageConfig {
&self.config
}
pub fn store(&self) -> &dyn ObjectStore {
self.store.as_ref()
}
pub async fn ingest_media(&self, data: &[u8], format: &str) -> StorageResult<String> {
let hash = blake3::hash(data);
let hex_digest = hash.to_hex();
let hash = format!("blake3:{hex_digest}");
let filename = format!("{hex_digest}.{format}");
debug!(
hash = %hash,
format = %format,
size = data.len(),
"ingesting media asset"
);
let store_path = self.build_store_path(&filename)?;
match self.store.head(&store_path).await {
Ok(meta) => {
debug!(
hash = %hash,
path = %store_path,
size = meta.size,
"asset already exists, skipping write"
);
return Ok(hash);
}
Err(e) => {
if !matches!(&e, object_store::Error::NotFound { .. }) {
error!(
hash = %hash,
path = %store_path,
error = %e,
"HEAD request failed before PUT"
);
return Err(StorageError::ObjectStore(e));
}
debug!(
hash = %hash,
path = %store_path,
"asset not found via HEAD, proceeding with PUT"
);
}
}
let blob = Bytes::copy_from_slice(data);
self.store
.put(&store_path, blob.into())
.await
.map_err(|e| {
error!(
hash = %hash,
path = %store_path,
error = %e,
"PUT failed during media ingestion"
);
StorageError::ObjectStore(e)
})?;
info!(
hash = %hash,
path = %store_path,
size = data.len(),
"media asset ingested successfully"
);
Ok(hash)
}
fn build_store_path(&self, filename: &str) -> StorageResult<StorePath> {
match self.config.backend {
StorageBackend::Local => {
let full_path = self
.config
.base_dir
.join(&self.config.assets_prefix)
.join(filename);
StorePath::from_absolute_path(&full_path).map_err(|e| {
error!(
path = %full_path.display(),
error = %e,
"failed to convert local filesystem path to store path"
);
StorageError::InvalidPath(format!(
"cannot convert '{}' to store path: {e}",
full_path.display()
))
})
}
StorageBackend::S3 => {
let key = format!("{}/{}", self.config.assets_prefix, filename);
Ok(StorePath::from(key))
}
}
}
}
type EngineInitResult = Result<StorageEngine, String>;
static ENGINE: OnceLock<EngineInitResult> = OnceLock::new();
pub fn get_engine() -> StorageResult<&'static StorageEngine> {
let result = ENGINE.get_or_init(|| {
info!("initialising storage engine (lazy)");
StorageEngine::from_env().map_err(|e| e.to_string())
});
match result {
Ok(engine) => Ok(engine),
Err(msg) => Err(StorageError::Init(msg.clone())),
}
}
fn resolve_path(raw: &str) -> PathBuf {
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME").or_else(|| {
std::env::var_os("USERPROFILE")
}) {
PathBuf::from(home).join(rest)
} else {
PathBuf::from(raw)
}
} else {
PathBuf::from(raw)
};
let absolute = if expanded.is_relative() {
if let Ok(cwd) = std::env::current_dir() {
cwd.join(&expanded)
} else {
expanded
}
} else {
expanded
};
std::fs::canonicalize(&absolute).unwrap_or(absolute)
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
fn in_memory_engine() -> StorageEngine {
StorageEngine {
store: Box::new(InMemory::new()),
config: StorageConfig {
backend: StorageBackend::S3,
base_dir: PathBuf::new(),
assets_prefix: ".nap-assets".to_string(),
bucket: "test-bucket".to_string(),
},
}
}
fn local_fs_engine(dir: &Path) -> StorageEngine {
let assets = dir.join(".nap-assets");
std::fs::create_dir_all(&assets).unwrap();
StorageEngine {
store: Box::new(LocalFileSystem::new()),
config: StorageConfig {
backend: StorageBackend::Local,
base_dir: dir.to_path_buf(),
assets_prefix: ".nap-assets".to_string(),
bucket: String::new(),
},
}
}
#[test]
fn test_resolve_path_expands_tilde() {
let resolved = resolve_path("~/nap-test-storage");
assert!(resolved.is_absolute(), "expected absolute path");
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect("home dir env var must be set in test env");
assert!(
resolved.starts_with(&home),
"expected '{:?}' to start with '{home}'",
resolved
);
}
#[test]
fn test_resolve_path_absolute_passthrough() {
let temp = std::env::temp_dir();
let p = temp.join("nap-test-abs");
let resolved = resolve_path(&p.to_string_lossy());
assert_eq!(resolved, p);
}
#[test]
fn test_resolve_path_relative_uses_cwd() {
let resolved = resolve_path("relative-dir");
assert!(
resolved.is_absolute(),
"relative path should be resolved to absolute"
);
}
#[test]
fn test_ensure_gitignore_creates_when_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path();
StorageEngine::ensure_gitignore(base).unwrap();
let gitignore = base.join(".gitignore");
assert!(gitignore.exists(), ".gitignore should have been created");
let content = std::fs::read_to_string(&gitignore).unwrap();
assert!(
content.contains(".nap-assets/"),
"expected .nap-assets/ in .gitignore, got: {content:?}"
);
}
#[test]
fn test_ensure_gitignore_appends_when_entry_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path();
std::fs::write(base.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
StorageEngine::ensure_gitignore(base).unwrap();
let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
assert!(
content.contains(".nap-assets/"),
"expected .nap-assets/ to be appended, got: {content:?}"
);
assert!(
content.contains("target/"),
"existing entries should be preserved"
);
assert!(
content.contains("node_modules/"),
"existing entries should be preserved"
);
}
#[test]
fn test_ensure_gitignore_noop_when_entry_present() {
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path();
std::fs::write(base.join(".gitignore"), "target/\n.nap-assets/\n").unwrap();
StorageEngine::ensure_gitignore(base).unwrap();
let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
assert_eq!(content, "target/\n.nap-assets/\n");
}
#[test]
fn test_build_store_path_local() {
let temp = std::env::temp_dir();
let engine = StorageEngine {
store: Box::new(LocalFileSystem::new()),
config: StorageConfig {
backend: StorageBackend::Local,
base_dir: temp.join("nap-test"),
assets_prefix: ".nap-assets".to_string(),
bucket: String::new(),
},
};
let path = engine.build_store_path("abc123.png").unwrap();
let path_str = path.to_string();
assert!(
path_str.contains(".nap-assets/abc123.png"),
"expected path containing '.nap-assets/abc123.png', got: {path_str}"
);
}
#[test]
fn test_build_store_path_s3() {
let engine = StorageEngine {
store: Box::new(LocalFileSystem::new()),
config: StorageConfig {
backend: StorageBackend::S3,
base_dir: PathBuf::new(),
assets_prefix: ".nap-assets".to_string(),
bucket: "my-bucket".to_string(),
},
};
let path = engine.build_store_path("abc123.png").unwrap();
assert_eq!(path.to_string(), ".nap-assets/abc123.png");
}
#[test]
fn test_build_store_path_local_from_absolute_path() {
let temp = std::env::temp_dir();
let engine = StorageEngine {
store: Box::new(LocalFileSystem::new()),
config: StorageConfig {
backend: StorageBackend::Local,
base_dir: temp.join("nap-test"),
assets_prefix: ".nap-assets".to_string(),
bucket: String::new(),
},
};
let path = engine.build_store_path("abc.png").unwrap();
let path_str = path.to_string();
assert!(
path_str.contains(".nap-assets/abc.png"),
"expected path to contain '.nap-assets/abc.png', got: {path_str}"
);
assert!(
!path_str.starts_with(".nap-assets/"),
"local paths should NOT be relative URL-style, got: {path_str}"
);
}
#[test]
fn test_blake3_hash_format() {
let data = b"hello world";
let hash = blake3::hash(data);
let hash_str = format!("blake3:{}", hash.to_hex());
assert!(
hash_str.starts_with("blake3:"),
"hash must start with blake3:"
);
assert_eq!(hash_str.len(), 71, "blake3:<64 hex chars> = 71 chars");
}
#[test]
fn test_storage_backend_display() {
assert_eq!(StorageBackend::Local.to_string(), "local");
assert_eq!(StorageBackend::S3.to_string(), "s3");
}
#[test]
fn test_storage_error_messages() {
let err = StorageError::Init("test".to_string());
assert_eq!(err.to_string(), "storage initialisation failed: test");
let err = StorageError::MissingEnvVar("NAP_S3_BUCKET".to_string());
assert_eq!(
err.to_string(),
"required environment variable `NAP_S3_BUCKET` is not set"
);
let err = StorageError::InvalidPath("bad".to_string());
assert_eq!(err.to_string(), "invalid storage path: bad");
}
#[tokio::test]
async fn test_ingest_media_returns_correct_hash_format() {
let engine = in_memory_engine();
let hash = engine.ingest_media(b"hello world", "txt").await.unwrap();
assert!(
hash.starts_with("blake3:"),
"hash must start with 'blake3:', got: {hash}"
);
assert_eq!(hash.len(), 71, "blake3:<64 hex chars> should be 71 chars");
}
#[tokio::test]
async fn test_ingest_media_is_idempotent() {
let engine = in_memory_engine();
let data = b"some-image-bytes-12345";
let hash1 = engine.ingest_media(data, "png").await.unwrap();
let hash2 = engine.ingest_media(data, "png").await.unwrap();
assert_eq!(hash1, hash2, "same data must produce same hash");
let hex = &hash1[7..];
let path = StorePath::from(format!(".nap-assets/{hex}.png"));
let meta = engine.store.head(&path).await.unwrap();
assert_eq!(
meta.size as usize,
data.len(),
"stored object size must match"
);
}
#[tokio::test]
async fn test_ingest_media_different_data_different_hash() {
let engine = in_memory_engine();
let hash_a = engine.ingest_media(b"hello", "txt").await.unwrap();
let hash_b = engine.ingest_media(b"world", "txt").await.unwrap();
assert_ne!(
hash_a, hash_b,
"different content must produce different hashes"
);
}
#[tokio::test]
async fn test_ingest_media_content_is_retrievable() {
let engine = in_memory_engine();
let original = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...";
let hash = engine.ingest_media(original, "png").await.unwrap();
let hex = &hash[7..];
let path = StorePath::from(format!(".nap-assets/{hex}.png"));
let result = engine.store.get(&path).await.unwrap();
let retrieved = result.bytes().await.unwrap();
assert_eq!(
retrieved.as_ref(),
original,
"retrieved content must match original"
);
}
#[tokio::test]
async fn test_ingest_media_content_integrity_verified_by_hash() {
let engine = in_memory_engine();
let data = b"verify-me-please";
let hash = engine.ingest_media(data, "bin").await.unwrap();
let expected_hash = format!("blake3:{}", blake3::hash(data).to_hex());
assert_eq!(hash, expected_hash, "hash must match BLAKE3 of content");
}
#[tokio::test]
async fn test_ingest_media_empty_data() {
let engine = in_memory_engine();
let hash = engine.ingest_media(b"", "empty").await.unwrap();
assert!(hash.starts_with("blake3:"), "empty data should still hash");
assert_eq!(
&hash[7..],
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
#[tokio::test]
async fn test_ingest_media_unknown_format() {
let engine = in_memory_engine();
let hash = engine
.ingest_media(b"\x00\x01\x02", "weird-format-123")
.await
.unwrap();
assert!(hash.starts_with("blake3:"));
let hex = &hash[7..];
let path = StorePath::from(format!(".nap-assets/{hex}.weird-format-123"));
let meta = engine.store.head(&path).await.unwrap();
assert_eq!(meta.size as usize, 3);
}
#[tokio::test]
async fn test_ingest_media_large_blob() {
let engine = in_memory_engine();
let data = vec![0xABu8; 1_000_000];
let hash = engine.ingest_media(&data, "bin").await.unwrap();
assert!(hash.starts_with("blake3:"));
let hex = &hash[7..];
let path = StorePath::from(format!(".nap-assets/{hex}.bin"));
let meta = engine.store.head(&path).await.unwrap();
assert_eq!(meta.size as usize, 1_000_000);
}
#[tokio::test]
async fn test_local_ingest_writes_file_to_disk() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = local_fs_engine(tmp.path());
let data = b"disk-content";
let hash = engine.ingest_media(data, "txt").await.unwrap();
let hex = &hash[7..];
let file_path = tmp.path().join(".nap-assets").join(format!("{hex}.txt"));
assert!(
file_path.exists(),
"file should exist on disk: {}",
file_path.display()
);
let on_disk = std::fs::read(&file_path).unwrap();
assert_eq!(on_disk, data, "file content must match original");
}
#[tokio::test]
async fn test_local_ingest_idempotent_skips_write() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = local_fs_engine(tmp.path());
let data = b"idempotent-data";
let hash1 = engine.ingest_media(data, "bin").await.unwrap();
let hash2 = engine.ingest_media(data, "bin").await.unwrap();
assert_eq!(hash1, hash2);
let hex = &hash1[7..];
let dir = tmp.path().join(".nap-assets");
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
let matching: Vec<_> = entries
.iter()
.filter(|e| e.file_name().to_str().is_some_and(|n| n.contains(hex)))
.collect();
assert_eq!(
matching.len(),
1,
"expected exactly one file matching the hash, got {}",
matching.len()
);
}
#[tokio::test]
async fn test_local_ingest_multiple_files() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = local_fs_engine(tmp.path());
let hash1 = engine.ingest_media(b"alpha", "txt").await.unwrap();
let hash2 = engine.ingest_media(b"beta", "txt").await.unwrap();
let hash3 = engine.ingest_media(b"gamma", "png").await.unwrap();
let assets_dir = tmp.path().join(".nap-assets");
for hash in &[hash1, hash2, hash3] {
let hex = &hash[7..];
let found = std::fs::read_dir(&assets_dir).unwrap().any(|e| {
e.ok()
.and_then(|e| e.file_name().to_str().map(|s| s.to_string()))
.is_some_and(|name| name.starts_with(hex))
});
assert!(
found,
"expected file starting with {hex} in assets directory"
);
}
}
#[tokio::test]
async fn test_local_ingest_resolves_content_by_hash() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = local_fs_engine(tmp.path());
let data = b"resolve-me";
let hash = engine.ingest_media(data, "bin").await.unwrap();
let content_hash = crate::content::ContentHash::parse(&hash).unwrap();
assert_eq!(content_hash.as_str(), &hash);
content_hash.verify(data).unwrap();
}
#[tokio::test]
async fn test_local_engine_config_matches() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = local_fs_engine(tmp.path());
assert_eq!(engine.config().backend, StorageBackend::Local);
assert_eq!(engine.config().base_dir, tmp.path());
assert_eq!(engine.config().assets_prefix, ".nap-assets");
assert!(engine.config().bucket.is_empty());
}
#[tokio::test]
async fn test_local_ingest_subdirectory_created() {
let tmp = tempfile::TempDir::new().unwrap();
let assets = tmp.path().join(".nap-assets");
if assets.exists() {
std::fs::remove_dir_all(&assets).unwrap();
}
assert!(!assets.exists(), "assets dir should not exist initially");
std::fs::create_dir_all(&assets).unwrap();
let engine = local_fs_engine(tmp.path());
let hash = engine.ingest_media(b"new-dir-test", "txt").await.unwrap();
let hex = &hash[7..];
let file_path = assets.join(format!("{hex}.txt"));
assert!(file_path.exists(), "file should exist in .nap-assets");
}
#[test]
fn test_from_env_rejects_unknown_backend() {
let err = StorageError::Init(
"unknown storage backend 'gcs'; expected 'local' or 's3'".to_string(),
);
assert!(err.to_string().contains("unknown storage backend"));
}
#[test]
fn test_storage_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = StorageError::Io(io_err);
assert!(err.to_string().contains("I/O error"));
}
#[test]
fn test_storage_error_from_object_store() {
let os_err = object_store::Error::Generic {
store: "test-store",
source: Box::new(std::io::Error::other("store error")),
};
let err = StorageError::ObjectStore(os_err);
assert!(err.to_string().contains("object store error"));
}
#[tokio::test]
async fn test_ingest_media_with_nonexistent_base_dir_still_works() {
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path().join("nested").join("path");
let assets = base.join(".nap-assets");
std::fs::create_dir_all(&assets).unwrap();
let engine = StorageEngine {
store: Box::new(LocalFileSystem::new()),
config: StorageConfig {
backend: StorageBackend::Local,
base_dir: base.clone(),
assets_prefix: ".nap-assets".to_string(),
bucket: String::new(),
},
};
let hash = engine.ingest_media(b"nested-test", "txt").await.unwrap();
let hex = &hash[7..];
let file_path = assets.join(format!("{hex}.txt"));
assert!(
file_path.exists(),
"file should exist at nested path: {}",
file_path.display()
);
}
#[tokio::test]
async fn test_ingest_media_multiple_formats_same_content() {
let engine = in_memory_engine();
let data = b"multi-format-content";
let hash_png = engine.ingest_media(data, "png").await.unwrap();
let hash_jpg = engine.ingest_media(data, "jpg").await.unwrap();
assert_eq!(hash_png, hash_jpg);
let hex = &hash_png[7..];
let path_png = StorePath::from(format!(".nap-assets/{hex}.png"));
let path_jpg = StorePath::from(format!(".nap-assets/{hex}.jpg"));
assert!(engine.store.head(&path_png).await.is_ok());
assert!(engine.store.head(&path_jpg).await.is_ok());
}
#[tokio::test]
async fn test_ingest_media_forces_unique_formats_in_store() {
let engine = in_memory_engine();
let data = b"same-bytes-different-extension";
let hash_a = engine.ingest_media(data, "mp4").await.unwrap();
let hash_b = engine.ingest_media(data, "wav").await.unwrap();
assert_eq!(hash_a, hash_b);
let hex = &hash_a[7..];
let path_mp4 = StorePath::from(format!(".nap-assets/{hex}.mp4"));
let path_wav = StorePath::from(format!(".nap-assets/{hex}.wav"));
let meta_mp4 = engine.store.head(&path_mp4).await.unwrap();
let meta_wav = engine.store.head(&path_wav).await.unwrap();
assert_eq!(meta_mp4.size, meta_wav.size);
}
}