Skip to main content

ant_node/upgrade/
binary_cache.rs

1//! Disk cache for downloaded upgrade binaries.
2//!
3//! When multiple ant-node instances detect the same upgrade, only the first
4//! one needs to download and verify the archive.  `BinaryCache` stores the
5//! extracted binary alongside a SHA-256 integrity metadata file so that
6//! subsequent nodes can copy it directly.
7//!
8//! **Security note:** SHA-256 is used only for cache integrity (detecting
9//! corruption or partial writes).  The actual security gate remains the
10//! ML-DSA-65 signature verification performed during the initial download.
11
12use crate::error::{Error, Result};
13use fs2::FileExt;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::fs::{self, File};
17use std::io::{Read, Write};
18use std::path::PathBuf;
19use tracing::{debug, warn};
20
21/// On-disk cache for downloaded upgrade binaries.
22#[derive(Clone)]
23pub struct BinaryCache {
24    /// Directory that holds cached binaries and metadata.
25    cache_dir: PathBuf,
26}
27
28/// Metadata written alongside each cached binary.
29#[derive(Serialize, Deserialize)]
30struct CachedBinaryMeta {
31    /// Semantic version string (e.g. "1.2.3").
32    version: String,
33    /// Hex-encoded SHA-256 digest of the cached binary.
34    sha256: String,
35    /// When the binary was cached (seconds since UNIX epoch).
36    cached_at_epoch_secs: u64,
37}
38
39impl BinaryCache {
40    /// Create a new binary cache backed by the given directory.
41    #[must_use]
42    pub fn new(cache_dir: PathBuf) -> Self {
43        Self { cache_dir }
44    }
45
46    /// Return the path where a cached binary for `version` would be stored.
47    #[must_use]
48    pub fn cached_binary_path(&self, version: &str) -> PathBuf {
49        let name = if cfg!(windows) {
50            format!("ant-node-{version}.exe")
51        } else {
52            format!("ant-node-{version}")
53        };
54        self.cache_dir.join(name)
55    }
56
57    /// Return the cached binary path if it exists and its SHA-256 matches
58    /// the stored metadata.  Returns `None` on any mismatch or error.
59    #[must_use]
60    pub fn get_verified(&self, version: &str) -> Option<PathBuf> {
61        let bin_path = self.cached_binary_path(version);
62        let meta_path = self.meta_path(version);
63
64        let meta_data = fs::read_to_string(&meta_path).ok()?;
65        let meta: CachedBinaryMeta = serde_json::from_str(&meta_data).ok()?;
66
67        if meta.version != version {
68            debug!("Binary cache version mismatch in metadata");
69            return None;
70        }
71
72        let actual_hash = sha256_file(&bin_path).ok()?;
73        if actual_hash != meta.sha256 {
74            warn!(
75                "Binary cache SHA-256 mismatch for version {version} (expected {}, got {})",
76                meta.sha256, actual_hash
77            );
78            return None;
79        }
80
81        Some(bin_path)
82    }
83
84    /// Store a binary in the cache.
85    ///
86    /// Uses a write-to-temp-then-rename strategy so that readers never
87    /// observe partially written files.  The metadata file is written last
88    /// so that `get_verified` only succeeds once both files are complete.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the binary cannot be read or the cache files
93    /// cannot be written.
94    pub fn store(&self, version: &str, source_path: &std::path::Path) -> Result<()> {
95        let hash = sha256_file(source_path)?;
96
97        let dest = self.cached_binary_path(version);
98        let meta_path = self.meta_path(version);
99
100        // Write binary to a temp file then rename into place.
101        // Remove dest first on Windows where rename fails if it exists.
102        let tmp_bin = self.cache_dir.join(format!(".ant-node-{version}.tmp"));
103        fs::copy(source_path, &tmp_bin)?;
104        let _ = fs::remove_file(&dest);
105        fs::rename(&tmp_bin, &dest)?;
106
107        let now = std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .map_err(|e| Error::Upgrade(format!("System clock error: {e}")))?
110            .as_secs();
111
112        let meta = CachedBinaryMeta {
113            version: version.to_string(),
114            sha256: hash,
115            cached_at_epoch_secs: now,
116        };
117
118        let meta_json = serde_json::to_string(&meta)
119            .map_err(|e| Error::Upgrade(format!("Failed to serialize binary cache meta: {e}")))?;
120
121        // Write metadata to a temp file then rename into place
122        let tmp_meta = self.cache_dir.join(format!(".ant-node-{version}.meta.tmp"));
123        let mut f = File::create(&tmp_meta)?;
124        f.write_all(meta_json.as_bytes())?;
125        f.sync_all()?;
126        drop(f);
127        let _ = fs::remove_file(&meta_path);
128        fs::rename(&tmp_meta, &meta_path)?;
129
130        debug!("Cached binary for version {version} at {}", dest.display());
131        Ok(())
132    }
133
134    /// Acquire an exclusive download lock and return the guard.
135    ///
136    /// This prevents multiple nodes from downloading the same archive
137    /// concurrently — the first acquires the lock and downloads, the rest
138    /// wait and then find the binary already cached.
139    ///
140    /// The lock is released when the returned guard is dropped.
141    ///
142    /// **Note:** `lock_exclusive()` blocks the calling thread.  Callers in
143    /// async contexts should wrap this call in `tokio::task::spawn_blocking`.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the lock file cannot be created or acquired.
148    pub fn acquire_download_lock(&self) -> Result<DownloadLockGuard> {
149        let lock_path = self.cache_dir.join("download.lock");
150        let lock = File::create(&lock_path)
151            .map_err(|e| Error::Upgrade(format!("Failed to create download lock: {e}")))?;
152        lock.lock_exclusive()
153            .map_err(|e| Error::Upgrade(format!("Failed to acquire download lock: {e}")))?;
154        Ok(DownloadLockGuard { _file: lock })
155    }
156
157    // -- private helpers -----------------------------------------------------
158
159    fn meta_path(&self, version: &str) -> PathBuf {
160        let name = if cfg!(windows) {
161            format!("ant-node-{version}.exe.meta.json")
162        } else {
163            format!("ant-node-{version}.meta.json")
164        };
165        self.cache_dir.join(name)
166    }
167}
168
169/// RAII guard that holds an exclusive download lock.
170///
171/// The underlying file lock is released when this guard is dropped.
172pub struct DownloadLockGuard {
173    _file: File,
174}
175
176/// Compute the hex-encoded SHA-256 digest of a file.
177fn sha256_file(path: &std::path::Path) -> Result<String> {
178    let mut file = File::open(path)?;
179    let mut hasher = Sha256::new();
180    let mut buf = [0u8; 8192];
181    loop {
182        let n = file
183            .read(&mut buf)
184            .map_err(|e| Error::Upgrade(format!("Failed to read file for hashing: {e}")))?;
185        if n == 0 {
186            break;
187        }
188        hasher.update(&buf[..n]);
189    }
190    Ok(hex::encode(hasher.finalize()))
191}
192
193// ---------------------------------------------------------------------------
194// Tests
195// ---------------------------------------------------------------------------
196
197#[cfg(test)]
198#[allow(clippy::unwrap_used, clippy::expect_used)]
199mod tests {
200    use super::*;
201    use tempfile::TempDir;
202
203    #[test]
204    fn test_miss_returns_none() {
205        let tmp = TempDir::new().unwrap();
206        let cache = BinaryCache::new(tmp.path().to_path_buf());
207        assert!(cache.get_verified("1.0.0").is_none());
208    }
209
210    #[test]
211    fn test_store_and_get_verified() {
212        let tmp = TempDir::new().unwrap();
213        let cache = BinaryCache::new(tmp.path().to_path_buf());
214
215        // Create a fake binary
216        let src = tmp.path().join("source-bin");
217        fs::write(&src, b"hello world binary").unwrap();
218
219        cache.store("1.2.3", &src).unwrap();
220
221        let result = cache.get_verified("1.2.3");
222        assert!(result.is_some());
223        let cached_path = result.unwrap();
224        assert_eq!(fs::read(&cached_path).unwrap(), b"hello world binary");
225    }
226
227    #[test]
228    fn test_sha256_mismatch_returns_none() {
229        let tmp = TempDir::new().unwrap();
230        let cache = BinaryCache::new(tmp.path().to_path_buf());
231
232        // Store a valid binary
233        let src = tmp.path().join("source-bin");
234        fs::write(&src, b"original content").unwrap();
235        cache.store("1.0.0", &src).unwrap();
236
237        // Corrupt the cached binary
238        let cached = cache.cached_binary_path("1.0.0");
239        fs::write(&cached, b"corrupted content").unwrap();
240
241        assert!(cache.get_verified("1.0.0").is_none());
242    }
243
244    #[test]
245    fn test_missing_meta_returns_none() {
246        let tmp = TempDir::new().unwrap();
247        let cache = BinaryCache::new(tmp.path().to_path_buf());
248
249        // Write a binary but no meta file
250        let cached = cache.cached_binary_path("1.0.0");
251        fs::write(&cached, b"binary data").unwrap();
252
253        assert!(cache.get_verified("1.0.0").is_none());
254    }
255}