Skip to main content

sdkt_storage/
cache.rs

1//! File-system based WASM metadata cache.
2//!
3//! Stores parsed `WasmMetadata` and raw `.wasm` binaries locally to
4//! avoid repeated RPC fetches for immutable contract code.
5
6use crate::error::StorageError;
7use directories::ProjectDirs;
8use sdkt_wasm::WasmMetadata;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12/// Info about the current state of the WASM cache for a specific network.
13#[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
21/// A local file-system cache for Soroban WASM metadata and binaries.
22pub struct WasmCache {
23    base_dir: PathBuf,
24}
25
26impl WasmCache {
27    /// Creates a new `WasmCache` instance.
28    ///
29    /// The directory location can be overridden via the `SDKT_CACHE_DIR` environment
30    /// variable (checked first). If unset or empty, uses the standard OS cache directory:
31    /// - Linux: `~/.cache/soroban-devkit/`
32    /// - macOS: `~/Library/Caches/org.SaboLabs.soroban-devkit/`
33    /// - Windows: `%LOCALAPPDATA%\SaboLabs\soroban-devkit\cache\`
34    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    /// Returns the base directory of the cache.
57    pub fn base_dir(&self) -> &Path {
58        &self.base_dir
59    }
60
61    /// Creates a cache instance targeting a specific directory (useful for testing).
62    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    /// Returns the directory for a specific network (e.g., `testnet`).
69    /// Creates the directory if it doesn't exist.
70    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    /// Returns `true` if metadata for the `wasm_hash` exists in the given `network`.
80    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    /// Retrieves parsed metadata from the cache.
87    ///
88    /// # Errors
89    /// Returns `StorageError::CorruptCache` if the JSON is malformed.
90    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    /// Writes both metadata and raw WASM to the cache using atomic tempfile renaming.
111    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        // Serialize JSON to string
127        let json = serde_json::to_string(metadata)
128            .map_err(|e| StorageError::Parse(format!("Failed to serialize metadata: {}", e)))?;
129
130        // Write to temporary files first to prevent corruption
131        fs::write(&meta_temp, json).map_err(StorageError::Io)?;
132        fs::write(&wasm_temp, wasm_bytes).map_err(StorageError::Io)?;
133
134        // Atomic rename
135        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    /// Removes a specific WASM entry (both JSON and `.wasm`) from the cache.
142    ///
143    /// A hash that is not cached is an error, not a silent success: callers
144    /// print "Removed" on `Ok`, and reporting success for a no-op reads as a
145    /// completed mutation to scripts and agents. Matches `identity delete` and
146    /// `network remove`, which surface `Not found:` and exit non-zero.
147    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    /// Clears all cached WASM entries for a specific network.
170    ///
171    /// Unlike `remove`, this stays truthful as a no-op success: "cleared" is
172    /// accurate for an empty cache (the post-condition holds), and unlike a
173    /// hash miss it is not evidence that a named target was deleted.
174    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    /// Retrieves usage statistics for a specific network.
184    /// A missing or unreadable cache directory is treated as an empty cache
185    /// (zero entries / zero size) on every platform. This keeps `cache info`
186    /// working on fresh CI runners (Linux/macOS/Windows) where the cache path
187    /// does not yet exist.
188    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        // A missing or unreadable directory is an empty cache, not an error.
197        // Swallowing the error (instead of `.map_err`) prevents ENOENT from
198        // propagating on platforms where the path is absent or a symlink
199        // target is missing (e.g. a fresh macOS runner).
200        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; // Count by JSON files
214                    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        // ensure files are physically gone
307        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        // A no-op removal must not report success: callers print "Removed" on
316        // Ok, and scripts/agents read that as a completed mutation.
317        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        // Create network dir and write garbage to the JSON file
350        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        // Simulate a fresh runner where the cache base dir does not exist yet
360        // (the macOS CI failure scenario). cache_info must succeed with zero
361        // counts instead of returning ENOENT.
362        let tmp = TempDir::new().unwrap();
363        // Point at a sub-path that does NOT exist.
364        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        // Base dir exists but the per-network subdir does not.
376        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    // ---------------------------------------------------------------------------
414    // Windows-specific regression tests
415    // ---------------------------------------------------------------------------
416
417    /// Verifies the production WasmCache::new() constructor resolves a
418    /// platform-appropriate cache directory on Windows.
419    ///
420    /// The test is read-only against the production constructor: it does not
421    /// write any cache data. It only verifies the resolved base directory
422    /// contains the expected OS-managed namespace.
423    #[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        // Windows: %LOCALAPPDATA%\SaboLabs\soroban-devkit\cache\
429        // We do not assert an absolute path (OS-managed); only the namespace.
430        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}