1use crate::error::StorageError;
7use directories::ProjectDirs;
8use sdkt_wasm::WasmMetadata;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct CacheInfo {
15 pub network: String,
16 pub entry_count: usize,
17 pub total_metadata_size_bytes: u64,
18 pub total_wasm_size_bytes: u64,
19}
20
21pub struct WasmCache {
23 base_dir: PathBuf,
24}
25
26impl WasmCache {
27 pub fn new() -> Result<Self, StorageError> {
35 if let Ok(dir) = std::env::var("SDKT_CACHE_DIR") {
36 let trimmed = dir.trim();
37 if !trimmed.is_empty() {
38 return Ok(Self {
39 base_dir: PathBuf::from(trimmed),
40 });
41 }
42 }
43
44 let proj_dirs =
45 ProjectDirs::from("org", "SaboLabs", "soroban-devkit").ok_or_else(|| {
46 StorageError::Io(std::io::Error::new(
47 std::io::ErrorKind::NotFound,
48 "Could not determine OS cache directory",
49 ))
50 })?;
51
52 let base_dir = proj_dirs.cache_dir().to_path_buf();
53 Ok(Self { base_dir })
54 }
55
56 pub fn base_dir(&self) -> &Path {
58 &self.base_dir
59 }
60
61 pub fn with_dir<P: AsRef<Path>>(path: P) -> Self {
63 Self {
64 base_dir: path.as_ref().to_path_buf(),
65 }
66 }
67
68 fn network_dir(&self, network: &str) -> Result<PathBuf, StorageError> {
71 validate_network_name(network)?;
72 let net_dir = self.base_dir.join("wasm").join(network);
73 if !net_dir.exists() {
74 fs::create_dir_all(&net_dir).map_err(StorageError::Io)?;
75 }
76 Ok(net_dir)
77 }
78
79 pub fn contains(&self, network: &str, wasm_hash: &str) -> Result<bool, StorageError> {
81 let net_dir = self.network_dir(network)?;
82 let meta_path = net_dir.join(format!("{}.json", wasm_hash));
83 Ok(meta_path.exists())
84 }
85
86 pub fn get(
91 &self,
92 network: &str,
93 wasm_hash: &str,
94 ) -> Result<Option<WasmMetadata>, StorageError> {
95 let net_dir = self.network_dir(network)?;
96 let meta_path = net_dir.join(format!("{}.json", wasm_hash));
97
98 if !meta_path.exists() {
99 return Ok(None);
100 }
101
102 let json = fs::read_to_string(&meta_path).map_err(StorageError::Io)?;
103 let metadata: WasmMetadata = serde_json::from_str(&json).map_err(|e| {
104 StorageError::CorruptCache(format!("Malformed JSON for {}: {}", wasm_hash, e))
105 })?;
106
107 Ok(Some(metadata))
108 }
109
110 pub fn put(
112 &self,
113 network: &str,
114 metadata: &WasmMetadata,
115 wasm_bytes: &[u8],
116 ) -> Result<(), StorageError> {
117 let net_dir = self.network_dir(network)?;
118 let hash = &metadata.hash;
119
120 let meta_path = net_dir.join(format!("{}.json", hash));
121 let wasm_path = net_dir.join(format!("{}.wasm", hash));
122
123 let meta_temp = net_dir.join(format!("{}.json.tmp", hash));
124 let wasm_temp = net_dir.join(format!("{}.wasm.tmp", hash));
125
126 let json = serde_json::to_string(metadata)
128 .map_err(|e| StorageError::Parse(format!("Failed to serialize metadata: {}", e)))?;
129
130 fs::write(&meta_temp, json).map_err(StorageError::Io)?;
132 fs::write(&wasm_temp, wasm_bytes).map_err(StorageError::Io)?;
133
134 fs::rename(&meta_temp, &meta_path).map_err(StorageError::Io)?;
136 fs::rename(&wasm_temp, &wasm_path).map_err(StorageError::Io)?;
137
138 Ok(())
139 }
140
141 pub fn remove(&self, network: &str, wasm_hash: &str) -> Result<(), StorageError> {
148 let net_dir = self.network_dir(network)?;
149 let meta_path = net_dir.join(format!("{}.json", wasm_hash));
150 let wasm_path = net_dir.join(format!("{}.wasm", wasm_hash));
151
152 if !meta_path.exists() && !wasm_path.exists() {
153 return Err(StorageError::NotFound(format!(
154 "WASM cache entry '{}' not found in {} cache",
155 wasm_hash, network
156 )));
157 }
158
159 if meta_path.exists() {
160 fs::remove_file(meta_path).map_err(StorageError::Io)?;
161 }
162 if wasm_path.exists() {
163 fs::remove_file(wasm_path).map_err(StorageError::Io)?;
164 }
165
166 Ok(())
167 }
168
169 pub fn clear(&self, network: &str) -> Result<(), StorageError> {
175 let net_dir = self.network_dir(network)?;
176 if net_dir.exists() {
177 fs::remove_dir_all(&net_dir).map_err(StorageError::Io)?;
178 fs::create_dir_all(&net_dir).map_err(StorageError::Io)?;
179 }
180 Ok(())
181 }
182
183 pub fn cache_info(&self, network: &str) -> Result<CacheInfo, StorageError> {
189 validate_network_name(network)?;
190 let net_dir = self.base_dir.join("wasm").join(network);
191
192 let mut entry_count = 0;
193 let mut total_metadata_size_bytes = 0;
194 let mut total_wasm_size_bytes = 0;
195
196 if let Ok(entries) = fs::read_dir(&net_dir) {
201 for entry in entries {
202 let entry = match entry {
203 Ok(e) => e,
204 Err(_) => continue,
205 };
206 let meta = match entry.metadata() {
207 Ok(m) => m,
208 Err(_) => continue,
209 };
210 let name = entry.file_name().to_string_lossy().to_string();
211
212 if name.ends_with(".json") {
213 entry_count += 1; total_metadata_size_bytes += meta.len();
215 } else if name.ends_with(".wasm") {
216 total_wasm_size_bytes += meta.len();
217 }
218 }
219 }
220
221 Ok(CacheInfo {
222 network: network.to_string(),
223 entry_count,
224 total_metadata_size_bytes,
225 total_wasm_size_bytes,
226 })
227 }
228}
229
230fn validate_network_name(network: &str) -> Result<(), StorageError> {
231 if network.is_empty()
232 || network == "."
233 || network == ".."
234 || network.contains('/')
235 || network.contains('\\')
236 || !matches!(
237 Path::new(network).components().next(),
238 Some(Component::Normal(_))
239 )
240 || Path::new(network).components().nth(1).is_some()
241 {
242 return Err(StorageError::ConfigError(format!(
243 "invalid network name '{}': must be a non-empty path component",
244 network
245 )));
246 }
247 Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use tempfile::TempDir;
254
255 fn get_temp_cache() -> (WasmCache, TempDir) {
256 let tmp_dir = TempDir::new().unwrap();
257 let cache = WasmCache::with_dir(tmp_dir.path());
258 (cache, tmp_dir)
259 }
260
261 fn dummy_metadata(hash: &str) -> WasmMetadata {
262 WasmMetadata {
263 hash: hash.to_string(),
264 size_bytes: 10,
265 version: 1,
266 exports: vec![],
267 imports: vec![],
268 custom_sections: vec![],
269 contract_meta: vec![],
270 function_count: 0,
271 memory: None,
272 table_count: 0,
273 global_count: 0,
274 data_segment_count: 0,
275 }
276 }
277
278 #[test]
279 fn test_put_get_contains() {
280 let (cache, _dir) = get_temp_cache();
281 let network = "testnet";
282 let hash = "abcd123";
283
284 assert!(!cache.contains(network, hash).unwrap());
285 assert!(cache.get(network, hash).unwrap().is_none());
286
287 let meta = dummy_metadata(hash);
288 cache.put(network, &meta, b"1234567890").unwrap();
289
290 assert!(cache.contains(network, hash).unwrap());
291 let retrieved = cache.get(network, hash).unwrap().unwrap();
292 assert_eq!(retrieved.hash, hash);
293 }
294
295 #[test]
296 fn test_remove() {
297 let (cache, _dir) = get_temp_cache();
298 let meta = dummy_metadata("test_hash");
299
300 cache.put("mainnet", &meta, b"xyz").unwrap();
301 assert!(cache.contains("mainnet", "test_hash").unwrap());
302
303 cache.remove("mainnet", "test_hash").unwrap();
304 assert!(!cache.contains("mainnet", "test_hash").unwrap());
305
306 let info = cache.cache_info("mainnet").unwrap();
308 assert_eq!(info.entry_count, 0);
309 assert_eq!(info.total_metadata_size_bytes, 0);
310 assert_eq!(info.total_wasm_size_bytes, 0);
311 }
312
313 #[test]
314 fn test_remove_missing_is_not_found_error() {
315 let (cache, _dir) = get_temp_cache();
318 let err = cache
319 .remove("mainnet", "never_cached")
320 .expect_err("removing an absent entry must fail");
321 assert!(
322 matches!(&err, StorageError::NotFound(m) if m.contains("never_cached")),
323 "expected NotFound, got {err:?}"
324 );
325 }
326
327 #[test]
328 fn test_clear() {
329 let (cache, _dir) = get_temp_cache();
330
331 cache.put("testnet", &dummy_metadata("1"), b"").unwrap();
332 cache.put("testnet", &dummy_metadata("2"), b"").unwrap();
333 cache.put("mainnet", &dummy_metadata("3"), b"").unwrap();
334
335 assert_eq!(cache.cache_info("testnet").unwrap().entry_count, 2);
336
337 cache.clear("testnet").unwrap();
338
339 assert_eq!(cache.cache_info("testnet").unwrap().entry_count, 0);
340 assert_eq!(cache.cache_info("mainnet").unwrap().entry_count, 1);
341 }
342
343 #[test]
344 fn test_corrupt_json() {
345 let (cache, _dir) = get_temp_cache();
346 let network = "testnet";
347 let hash = "corrupt";
348
349 let net_dir = cache.network_dir(network).unwrap();
351 fs::write(net_dir.join(format!("{}.json", hash)), b"{ invalid_json").unwrap();
352
353 let err = cache.get(network, hash).unwrap_err();
354 assert!(matches!(err, StorageError::CorruptCache(_)));
355 }
356
357 #[test]
358 fn test_missing_cache_dir_is_empty() {
359 let tmp = TempDir::new().unwrap();
363 let cache = WasmCache::with_dir(tmp.path().join("does-not-exist-yet"));
365
366 let info = cache.cache_info("testnet").unwrap();
367 assert_eq!(info.entry_count, 0);
368 assert_eq!(info.total_metadata_size_bytes, 0);
369 assert_eq!(info.total_wasm_size_bytes, 0);
370 assert_eq!(info.network, "testnet");
371 }
372
373 #[test]
374 fn test_cache_info_empty_network_dir() {
375 let (cache, _dir) = get_temp_cache();
377 let info = cache.cache_info("nonexistent-network").unwrap();
378 assert_eq!(info.entry_count, 0);
379 }
380
381 #[test]
382 fn rejects_path_traversal_before_filesystem_access() {
383 let (cache, dir) = get_temp_cache();
384 let outside = dir.path().join("outside");
385 fs::create_dir_all(&outside).unwrap();
386
387 assert!(matches!(
388 cache.clear("../outside"),
389 Err(StorageError::ConfigError(_))
390 ));
391 assert!(outside.exists());
392 assert!(matches!(
393 cache.remove("nested/name", "hash"),
394 Err(StorageError::ConfigError(_))
395 ));
396 assert!(matches!(
397 cache.cache_info(""),
398 Err(StorageError::ConfigError(_))
399 ));
400 }
401
402 #[cfg(windows)]
403 #[test]
404 fn rejects_drive_relative_network_name() {
405 let (cache, _dir) = get_temp_cache();
406
407 assert!(matches!(
408 cache.clear("C:outside"),
409 Err(StorageError::ConfigError(_))
410 ));
411 }
412
413 #[cfg(windows)]
424 #[test]
425 fn test_new_resolves_windows_cache_path() {
426 let cache = WasmCache::new().expect("WasmCache::new() must succeed on Windows");
427
428 let path_str = cache.base_dir.to_string_lossy().to_lowercase();
431 assert!(
432 path_str.contains("sabolabs"),
433 "Windows cache path should contain the 'SaboLabs' namespace, got: {}",
434 cache.base_dir.display()
435 );
436 assert!(
437 path_str.contains("soroban-devkit"),
438 "Windows cache path should contain the 'soroban-devkit' app name, got: {}",
439 cache.base_dir.display()
440 );
441 }
442
443 #[test]
444 fn test_new_with_env_var_override() {
445 let tmp = TempDir::new().unwrap();
446 let key = "SDKT_CACHE_DIR";
447 let prev = std::env::var_os(key);
448 std::env::set_var(key, tmp.path());
449
450 let cache = WasmCache::new().expect("WasmCache::new() with SDKT_CACHE_DIR");
451 assert_eq!(cache.base_dir(), tmp.path());
452
453 if let Some(p) = prev {
454 std::env::set_var(key, p);
455 } else {
456 std::env::remove_var(key);
457 }
458 }
459}