use crate::error::StorageError;
use directories::ProjectDirs;
use sdkt_wasm::WasmMetadata;
use std::fs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub struct CacheInfo {
pub network: String,
pub entry_count: usize,
pub total_metadata_size_bytes: u64,
pub total_wasm_size_bytes: u64,
}
pub struct WasmCache {
base_dir: PathBuf,
}
impl WasmCache {
pub fn new() -> Result<Self, StorageError> {
if let Ok(dir) = std::env::var("SDKT_CACHE_DIR") {
let trimmed = dir.trim();
if !trimmed.is_empty() {
return Ok(Self {
base_dir: PathBuf::from(trimmed),
});
}
}
let proj_dirs =
ProjectDirs::from("org", "SaboLabs", "soroban-devkit").ok_or_else(|| {
StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine OS cache directory",
))
})?;
let base_dir = proj_dirs.cache_dir().to_path_buf();
Ok(Self { base_dir })
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
pub fn with_dir<P: AsRef<Path>>(path: P) -> Self {
Self {
base_dir: path.as_ref().to_path_buf(),
}
}
fn network_dir(&self, network: &str) -> Result<PathBuf, StorageError> {
validate_network_name(network)?;
let net_dir = self.base_dir.join("wasm").join(network);
if !net_dir.exists() {
fs::create_dir_all(&net_dir).map_err(StorageError::Io)?;
}
Ok(net_dir)
}
pub fn contains(&self, network: &str, wasm_hash: &str) -> Result<bool, StorageError> {
let net_dir = self.network_dir(network)?;
let meta_path = net_dir.join(format!("{}.json", wasm_hash));
Ok(meta_path.exists())
}
pub fn get(
&self,
network: &str,
wasm_hash: &str,
) -> Result<Option<WasmMetadata>, StorageError> {
let net_dir = self.network_dir(network)?;
let meta_path = net_dir.join(format!("{}.json", wasm_hash));
if !meta_path.exists() {
return Ok(None);
}
let json = fs::read_to_string(&meta_path).map_err(StorageError::Io)?;
let metadata: WasmMetadata = serde_json::from_str(&json).map_err(|e| {
StorageError::CorruptCache(format!("Malformed JSON for {}: {}", wasm_hash, e))
})?;
Ok(Some(metadata))
}
pub fn put(
&self,
network: &str,
metadata: &WasmMetadata,
wasm_bytes: &[u8],
) -> Result<(), StorageError> {
let net_dir = self.network_dir(network)?;
let hash = &metadata.hash;
let meta_path = net_dir.join(format!("{}.json", hash));
let wasm_path = net_dir.join(format!("{}.wasm", hash));
let meta_temp = net_dir.join(format!("{}.json.tmp", hash));
let wasm_temp = net_dir.join(format!("{}.wasm.tmp", hash));
let json = serde_json::to_string(metadata)
.map_err(|e| StorageError::Parse(format!("Failed to serialize metadata: {}", e)))?;
fs::write(&meta_temp, json).map_err(StorageError::Io)?;
fs::write(&wasm_temp, wasm_bytes).map_err(StorageError::Io)?;
fs::rename(&meta_temp, &meta_path).map_err(StorageError::Io)?;
fs::rename(&wasm_temp, &wasm_path).map_err(StorageError::Io)?;
Ok(())
}
pub fn remove(&self, network: &str, wasm_hash: &str) -> Result<(), StorageError> {
let net_dir = self.network_dir(network)?;
let meta_path = net_dir.join(format!("{}.json", wasm_hash));
let wasm_path = net_dir.join(format!("{}.wasm", wasm_hash));
if !meta_path.exists() && !wasm_path.exists() {
return Err(StorageError::NotFound(format!(
"WASM cache entry '{}' not found in {} cache",
wasm_hash, network
)));
}
if meta_path.exists() {
fs::remove_file(meta_path).map_err(StorageError::Io)?;
}
if wasm_path.exists() {
fs::remove_file(wasm_path).map_err(StorageError::Io)?;
}
Ok(())
}
pub fn clear(&self, network: &str) -> Result<(), StorageError> {
let net_dir = self.network_dir(network)?;
if net_dir.exists() {
fs::remove_dir_all(&net_dir).map_err(StorageError::Io)?;
fs::create_dir_all(&net_dir).map_err(StorageError::Io)?;
}
Ok(())
}
pub fn cache_info(&self, network: &str) -> Result<CacheInfo, StorageError> {
validate_network_name(network)?;
let net_dir = self.base_dir.join("wasm").join(network);
let mut entry_count = 0;
let mut total_metadata_size_bytes = 0;
let mut total_wasm_size_bytes = 0;
if let Ok(entries) = fs::read_dir(&net_dir) {
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".json") {
entry_count += 1; total_metadata_size_bytes += meta.len();
} else if name.ends_with(".wasm") {
total_wasm_size_bytes += meta.len();
}
}
}
Ok(CacheInfo {
network: network.to_string(),
entry_count,
total_metadata_size_bytes,
total_wasm_size_bytes,
})
}
}
fn validate_network_name(network: &str) -> Result<(), StorageError> {
if network.is_empty()
|| network == "."
|| network == ".."
|| network.contains('/')
|| network.contains('\\')
|| !matches!(
Path::new(network).components().next(),
Some(Component::Normal(_))
)
|| Path::new(network).components().nth(1).is_some()
{
return Err(StorageError::ConfigError(format!(
"invalid network name '{}': must be a non-empty path component",
network
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn get_temp_cache() -> (WasmCache, TempDir) {
let tmp_dir = TempDir::new().unwrap();
let cache = WasmCache::with_dir(tmp_dir.path());
(cache, tmp_dir)
}
fn dummy_metadata(hash: &str) -> WasmMetadata {
WasmMetadata {
hash: hash.to_string(),
size_bytes: 10,
version: 1,
exports: vec![],
imports: vec![],
custom_sections: vec![],
contract_meta: vec![],
function_count: 0,
memory: None,
table_count: 0,
global_count: 0,
data_segment_count: 0,
}
}
#[test]
fn test_put_get_contains() {
let (cache, _dir) = get_temp_cache();
let network = "testnet";
let hash = "abcd123";
assert!(!cache.contains(network, hash).unwrap());
assert!(cache.get(network, hash).unwrap().is_none());
let meta = dummy_metadata(hash);
cache.put(network, &meta, b"1234567890").unwrap();
assert!(cache.contains(network, hash).unwrap());
let retrieved = cache.get(network, hash).unwrap().unwrap();
assert_eq!(retrieved.hash, hash);
}
#[test]
fn test_remove() {
let (cache, _dir) = get_temp_cache();
let meta = dummy_metadata("test_hash");
cache.put("mainnet", &meta, b"xyz").unwrap();
assert!(cache.contains("mainnet", "test_hash").unwrap());
cache.remove("mainnet", "test_hash").unwrap();
assert!(!cache.contains("mainnet", "test_hash").unwrap());
let info = cache.cache_info("mainnet").unwrap();
assert_eq!(info.entry_count, 0);
assert_eq!(info.total_metadata_size_bytes, 0);
assert_eq!(info.total_wasm_size_bytes, 0);
}
#[test]
fn test_remove_missing_is_not_found_error() {
let (cache, _dir) = get_temp_cache();
let err = cache
.remove("mainnet", "never_cached")
.expect_err("removing an absent entry must fail");
assert!(
matches!(&err, StorageError::NotFound(m) if m.contains("never_cached")),
"expected NotFound, got {err:?}"
);
}
#[test]
fn test_clear() {
let (cache, _dir) = get_temp_cache();
cache.put("testnet", &dummy_metadata("1"), b"").unwrap();
cache.put("testnet", &dummy_metadata("2"), b"").unwrap();
cache.put("mainnet", &dummy_metadata("3"), b"").unwrap();
assert_eq!(cache.cache_info("testnet").unwrap().entry_count, 2);
cache.clear("testnet").unwrap();
assert_eq!(cache.cache_info("testnet").unwrap().entry_count, 0);
assert_eq!(cache.cache_info("mainnet").unwrap().entry_count, 1);
}
#[test]
fn test_corrupt_json() {
let (cache, _dir) = get_temp_cache();
let network = "testnet";
let hash = "corrupt";
let net_dir = cache.network_dir(network).unwrap();
fs::write(net_dir.join(format!("{}.json", hash)), b"{ invalid_json").unwrap();
let err = cache.get(network, hash).unwrap_err();
assert!(matches!(err, StorageError::CorruptCache(_)));
}
#[test]
fn test_missing_cache_dir_is_empty() {
let tmp = TempDir::new().unwrap();
let cache = WasmCache::with_dir(tmp.path().join("does-not-exist-yet"));
let info = cache.cache_info("testnet").unwrap();
assert_eq!(info.entry_count, 0);
assert_eq!(info.total_metadata_size_bytes, 0);
assert_eq!(info.total_wasm_size_bytes, 0);
assert_eq!(info.network, "testnet");
}
#[test]
fn test_cache_info_empty_network_dir() {
let (cache, _dir) = get_temp_cache();
let info = cache.cache_info("nonexistent-network").unwrap();
assert_eq!(info.entry_count, 0);
}
#[test]
fn rejects_path_traversal_before_filesystem_access() {
let (cache, dir) = get_temp_cache();
let outside = dir.path().join("outside");
fs::create_dir_all(&outside).unwrap();
assert!(matches!(
cache.clear("../outside"),
Err(StorageError::ConfigError(_))
));
assert!(outside.exists());
assert!(matches!(
cache.remove("nested/name", "hash"),
Err(StorageError::ConfigError(_))
));
assert!(matches!(
cache.cache_info(""),
Err(StorageError::ConfigError(_))
));
}
#[cfg(windows)]
#[test]
fn rejects_drive_relative_network_name() {
let (cache, _dir) = get_temp_cache();
assert!(matches!(
cache.clear("C:outside"),
Err(StorageError::ConfigError(_))
));
}
#[cfg(windows)]
#[test]
fn test_new_resolves_windows_cache_path() {
let cache = WasmCache::new().expect("WasmCache::new() must succeed on Windows");
let path_str = cache.base_dir.to_string_lossy().to_lowercase();
assert!(
path_str.contains("sabolabs"),
"Windows cache path should contain the 'SaboLabs' namespace, got: {}",
cache.base_dir.display()
);
assert!(
path_str.contains("soroban-devkit"),
"Windows cache path should contain the 'soroban-devkit' app name, got: {}",
cache.base_dir.display()
);
}
#[test]
fn test_new_with_env_var_override() {
let tmp = TempDir::new().unwrap();
let key = "SDKT_CACHE_DIR";
let prev = std::env::var_os(key);
std::env::set_var(key, tmp.path());
let cache = WasmCache::new().expect("WasmCache::new() with SDKT_CACHE_DIR");
assert_eq!(cache.base_dir(), tmp.path());
if let Some(p) = prev {
std::env::set_var(key, p);
} else {
std::env::remove_var(key);
}
}
}