use std::fs;
use std::path::{Path, PathBuf};
use polykit_core::error::{Error, Result};
use polykit_core::remote_cache::Artifact;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StorageMetadata {
pub hash: String,
pub size: u64,
pub created_at: u64,
pub cache_key_hash: String,
}
pub struct Storage {
storage_root: PathBuf,
max_artifact_size: u64,
}
impl Storage {
pub fn new(storage_root: impl AsRef<Path>, max_artifact_size: u64) -> Result<Self> {
let storage_root = storage_root.as_ref().to_path_buf();
fs::create_dir_all(&storage_root).map_err(Error::Io)?;
let tmp_dir = storage_root.join("tmp");
fs::create_dir_all(&tmp_dir).map_err(Error::Io)?;
Ok(Self {
storage_root,
max_artifact_size,
})
}
fn shard_path(&self, cache_key: &str) -> PathBuf {
if cache_key.len() < 4 {
return self.storage_root.join("00").join("00");
}
let prefix = &cache_key[..4];
let dir1 = &prefix[..2];
let dir2 = &prefix[2..4];
self.storage_root.join(dir1).join(dir2)
}
fn artifact_path(&self, cache_key: &str) -> PathBuf {
self.shard_path(cache_key).join(format!("{}.zst", cache_key))
}
fn metadata_path(&self, cache_key: &str) -> PathBuf {
self.shard_path(cache_key).join(format!("{}.json", cache_key))
}
pub fn has_artifact(&self, cache_key: &str) -> bool {
self.artifact_path(cache_key).exists()
}
fn temp_path(&self) -> PathBuf {
let uuid = uuid::Uuid::new_v4();
self.storage_root.join("tmp").join(format!("{}.tmp", uuid))
}
pub async fn store_artifact(
&self,
cache_key: &str,
data: Vec<u8>,
hash: String,
artifact: &Artifact,
) -> Result<()> {
if !cache_key.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::Adapter {
package: "storage".to_string(),
message: format!("Invalid cache key format: {}", cache_key),
});
}
if data.len() as u64 > self.max_artifact_size {
return Err(Error::Adapter {
package: "storage".to_string(),
message: format!(
"Artifact size {} exceeds maximum {}",
data.len(),
self.max_artifact_size
),
});
}
if self.has_artifact(cache_key) {
return Err(Error::Adapter {
package: "storage".to_string(),
message: format!("Artifact {} already exists", cache_key),
});
}
let temp_path = self.temp_path();
tokio::fs::write(&temp_path, &data).await.map_err(Error::Io)?;
let shard_dir = self.shard_path(cache_key);
fs::create_dir_all(&shard_dir).map_err(Error::Io)?;
let artifact_path = self.artifact_path(cache_key);
fs::rename(&temp_path, &artifact_path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
Error::Io(e)
})?;
let artifact_metadata = artifact.metadata();
let storage_metadata = StorageMetadata {
hash,
size: data.len() as u64,
created_at: artifact_metadata.created_at,
cache_key_hash: artifact_metadata.cache_key_hash.clone(),
};
let metadata_json = serde_json::to_string(&storage_metadata).map_err(|e| Error::Adapter {
package: "storage".to_string(),
message: format!("Failed to serialize metadata: {}", e),
})?;
let metadata_path = self.metadata_path(cache_key);
fs::write(&metadata_path, metadata_json).map_err(Error::Io)?;
Ok(())
}
pub async fn read_artifact(&self, cache_key: &str) -> Result<Vec<u8>> {
let artifact_path = self.artifact_path(cache_key);
if !artifact_path.exists() {
return Err(Error::Adapter {
package: "storage".to_string(),
message: format!("Artifact {} not found", cache_key),
});
}
tokio::fs::read(&artifact_path).await.map_err(Error::Io)
}
pub async fn read_metadata(&self, cache_key: &str) -> Result<StorageMetadata> {
let metadata_path = self.metadata_path(cache_key);
if !metadata_path.exists() {
return Err(Error::Adapter {
package: "storage".to_string(),
message: format!("Metadata for {} not found", cache_key),
});
}
let content = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(Error::Io)?;
serde_json::from_str(&content).map_err(|e| Error::Adapter {
package: "storage".to_string(),
message: format!("Failed to parse metadata: {}", e),
})
}
pub fn max_artifact_size(&self) -> u64 {
self.max_artifact_size
}
pub fn cleanup_temp_files(&self) -> Result<()> {
let tmp_dir = self.storage_root.join("tmp");
if !tmp_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&tmp_dir).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("tmp") {
let _ = fs::remove_file(&path);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_storage_sharding() {
let temp_dir = TempDir::new().unwrap();
let storage = Storage::new(temp_dir.path(), 1024 * 1024).unwrap();
let cache_key = "aabbccdd11223344556677889900aabbccddeeff";
let shard_path = storage.shard_path(cache_key);
assert!(shard_path.ends_with("aa/bb"));
}
#[tokio::test]
async fn test_storage_atomic_write() {
let temp_dir = TempDir::new().unwrap();
let storage = Storage::new(temp_dir.path(), 1024 * 1024).unwrap();
let cache_key = "aabbccdd11223344556677889900aabbccddeeff";
let data = b"test data".to_vec();
let hash = "test_hash".to_string();
let artifact = polykit_core::remote_cache::Artifact::new(
"test".to_string(),
"build".to_string(),
"echo".to_string(),
cache_key.to_string(),
std::collections::BTreeMap::new(),
)
.unwrap();
storage
.store_artifact(cache_key, data.clone(), hash, &artifact)
.await
.unwrap();
assert!(storage.has_artifact(cache_key));
let read_data = storage.read_artifact(cache_key).await.unwrap();
assert_eq!(read_data, data);
let read_metadata = storage.read_metadata(cache_key).await.unwrap();
assert_eq!(read_metadata.cache_key_hash, cache_key);
}
#[tokio::test]
async fn test_storage_immutable() {
let temp_dir = TempDir::new().unwrap();
let storage = Storage::new(temp_dir.path(), 1024 * 1024).unwrap();
let cache_key = "aabbccdd11223344556677889900aabbccddeeff";
let data = b"test data".to_vec();
let hash = "test_hash".to_string();
let artifact1 = polykit_core::remote_cache::Artifact::new(
"test".to_string(),
"build".to_string(),
"echo".to_string(),
cache_key.to_string(),
std::collections::BTreeMap::new(),
)
.unwrap();
storage
.store_artifact(cache_key, data, hash, &artifact1)
.await
.unwrap();
let artifact2 = polykit_core::remote_cache::Artifact::new(
"test".to_string(),
"build".to_string(),
"echo".to_string(),
cache_key.to_string(),
std::collections::BTreeMap::new(),
)
.unwrap();
let result = storage
.store_artifact(cache_key, b"different".to_vec(), "hash2".to_string(), &artifact2)
.await;
assert!(result.is_err());
}
}