Skip to main content

ant_node/upgrade/
mod.rs

1//! Auto-upgrade system with ML-DSA signature verification.
2//!
3//! This module handles:
4//! - Polling GitHub releases for new versions
5//! - Verifying ML-DSA-65 signatures on binaries
6//! - Replacing the running binary with rollback support
7//! - Staged rollout to prevent mass network restarts
8//! - Auto-apply: download, extract, verify, replace, restart
9
10mod apply;
11mod binary_cache;
12mod cache_dir;
13mod monitor;
14mod release_cache;
15mod rollout;
16mod signature;
17
18pub use apply::{AutoApplyUpgrader, RESTART_EXIT_CODE};
19pub use binary_cache::BinaryCache;
20pub use cache_dir::upgrade_cache_dir;
21pub use monitor::{find_platform_asset, version_from_tag, Asset, GitHubRelease, UpgradeMonitor};
22pub use release_cache::ReleaseCache;
23pub use rollout::StagedRollout;
24pub use signature::{
25    verify_binary_signature, verify_binary_signature_with_key, verify_from_file,
26    verify_from_file_with_key, PUBLIC_KEY_SIZE, SIGNATURE_SIZE, SIGNING_CONTEXT,
27};
28
29use crate::error::{Error, Result};
30use semver::Version;
31use std::fs;
32use std::path::Path;
33use tracing::{debug, info, warn};
34
35/// Maximum allowed upgrade binary size (200 MiB).
36///
37/// This is a sanity limit to prevent memory exhaustion during ML-DSA verification,
38/// which requires loading the full binary into RAM.
39const MAX_BINARY_SIZE_BYTES: usize = 200 * 1024 * 1024;
40
41/// Information about an available upgrade.
42#[derive(Debug, Clone)]
43pub struct UpgradeInfo {
44    /// The new version.
45    pub version: Version,
46    /// Download URL for the binary.
47    pub download_url: String,
48    /// Signature URL.
49    pub signature_url: String,
50    /// Release notes.
51    pub release_notes: String,
52}
53
54/// Result of an upgrade operation.
55#[derive(Debug)]
56pub enum UpgradeResult {
57    /// Upgrade was successful and the process should exit to complete the restart.
58    Success {
59        /// The new version.
60        version: Version,
61        /// Exit code to use when terminating the process.
62        /// The caller should trigger graceful shutdown, then exit with this code.
63        exit_code: i32,
64    },
65    /// Upgrade failed, rolled back.
66    RolledBack {
67        /// Error that caused the rollback.
68        reason: String,
69    },
70    /// No upgrade available.
71    NoUpgrade,
72}
73
74/// Upgrade orchestrator with rollback support.
75///
76/// Handles the complete upgrade lifecycle:
77/// 1. Validate upgrade (prevent downgrade)
78/// 2. Download new binary and signature
79/// 3. Verify ML-DSA-65 signature
80/// 4. Create backup of current binary
81/// 5. Atomic replacement
82/// 6. Rollback on failure
83pub struct Upgrader {
84    /// Current running version.
85    current_version: Version,
86    /// HTTP client for downloads.
87    client: reqwest::Client,
88}
89
90impl Upgrader {
91    /// Create a new upgrader with the current package version.
92    #[must_use]
93    pub fn new() -> Self {
94        let current_version =
95            Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0));
96
97        Self {
98            current_version,
99            client: reqwest::Client::new(),
100        }
101    }
102
103    /// Create an upgrader with a custom version (for testing).
104    #[cfg(test)]
105    #[must_use]
106    pub fn with_version(version: Version) -> Self {
107        Self {
108            current_version: version,
109            client: reqwest::Client::new(),
110        }
111    }
112
113    /// Get the current version.
114    #[must_use]
115    pub fn current_version(&self) -> &Version {
116        &self.current_version
117    }
118
119    /// Validate that the upgrade is allowed (prevents downgrade).
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the target version is older than or equal to current.
124    pub fn validate_upgrade(&self, info: &UpgradeInfo) -> Result<()> {
125        if info.version <= self.current_version {
126            return Err(Error::Upgrade(format!(
127                "Cannot downgrade from {} to {}",
128                self.current_version, info.version
129            )));
130        }
131        Ok(())
132    }
133
134    /// Create a backup of the current binary.
135    ///
136    /// # Arguments
137    ///
138    /// * `current` - Path to the current binary
139    /// * `rollback_dir` - Directory to store the backup
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if the backup cannot be created.
144    pub fn create_backup(&self, current: &Path, rollback_dir: &Path) -> Result<()> {
145        let filename = current
146            .file_name()
147            .ok_or_else(|| Error::Upgrade("Invalid binary path".to_string()))?;
148
149        let backup_path = rollback_dir.join(format!("{}.backup", filename.to_string_lossy()));
150
151        debug!("Creating backup at: {}", backup_path.display());
152        fs::copy(current, &backup_path)?;
153        Ok(())
154    }
155
156    /// Restore binary from backup.
157    ///
158    /// # Arguments
159    ///
160    /// * `current` - Path to restore to
161    /// * `rollback_dir` - Directory containing the backup
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the backup cannot be restored.
166    pub fn restore_from_backup(&self, current: &Path, rollback_dir: &Path) -> Result<()> {
167        let filename = current
168            .file_name()
169            .ok_or_else(|| Error::Upgrade("Invalid binary path".to_string()))?;
170
171        let backup_path = rollback_dir.join(format!("{}.backup", filename.to_string_lossy()));
172
173        if !backup_path.exists() {
174            return Err(Error::Upgrade("No backup found for rollback".to_string()));
175        }
176
177        info!("Restoring from backup: {}", backup_path.display());
178        fs::copy(&backup_path, current)?;
179        Ok(())
180    }
181
182    /// Atomically replace the binary (rename on POSIX).
183    ///
184    /// Preserves file permissions from the original binary.
185    ///
186    /// # Arguments
187    ///
188    /// * `new_binary` - Path to the new binary
189    /// * `target` - Path to replace
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the replacement fails.
194    pub fn atomic_replace(&self, new_binary: &Path, target: &Path) -> Result<()> {
195        // Preserve original permissions on Unix
196        #[cfg(unix)]
197        {
198            if let Ok(meta) = fs::metadata(target) {
199                let perms = meta.permissions();
200                fs::set_permissions(new_binary, perms)?;
201            }
202        }
203
204        // Atomic rename
205        fs::rename(new_binary, target)?;
206        debug!("Atomic replacement complete");
207        Ok(())
208    }
209
210    /// Download a file to the specified path.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the download fails.
215    async fn download(&self, url: &str, dest: &Path) -> Result<()> {
216        debug!("Downloading: {}", url);
217
218        let response = self
219            .client
220            .get(url)
221            .send()
222            .await
223            .map_err(|e| Error::Network(format!("Download failed: {e}")))?;
224
225        if !response.status().is_success() {
226            return Err(Error::Network(format!(
227                "Download returned status: {}",
228                response.status()
229            )));
230        }
231
232        let bytes = response
233            .bytes()
234            .await
235            .map_err(|e| Error::Network(format!("Failed to read response: {e}")))?;
236
237        Self::enforce_max_binary_size(bytes.len())?;
238
239        fs::write(dest, &bytes)?;
240        debug!("Downloaded {} bytes to {}", bytes.len(), dest.display());
241        Ok(())
242    }
243
244    /// Ensure the downloaded binary is within a sane size limit.
245    fn enforce_max_binary_size(len: usize) -> Result<()> {
246        if len > MAX_BINARY_SIZE_BYTES {
247            return Err(Error::Upgrade(format!(
248                "Downloaded binary too large: {len} bytes (max {MAX_BINARY_SIZE_BYTES})"
249            )));
250        }
251        Ok(())
252    }
253
254    /// Create a temp directory for upgrades in the same directory as the target binary.
255    ///
256    /// Ensures `fs::rename` is atomic by keeping source/target on the same filesystem.
257    fn create_tempdir_in_target_dir(current_binary: &Path) -> Result<tempfile::TempDir> {
258        let target_dir = current_binary
259            .parent()
260            .ok_or_else(|| Error::Upgrade("Current binary has no parent directory".to_string()))?;
261
262        tempfile::Builder::new()
263            .prefix("ant-upgrade-")
264            .tempdir_in(target_dir)
265            .map_err(|e| Error::Upgrade(format!("Failed to create temp dir: {e}")))
266    }
267
268    /// Perform upgrade with rollback support.
269    ///
270    /// This is the main upgrade entry point. It:
271    /// 1. Validates the upgrade (prevents downgrade)
272    /// 2. Creates a backup of the current binary
273    /// 3. Downloads the new binary and signature
274    /// 4. Verifies the ML-DSA-65 signature
275    /// 5. Atomically replaces the binary
276    /// 6. Rolls back on any failure
277    ///
278    /// # Arguments
279    ///
280    /// * `info` - Information about the upgrade to perform
281    /// * `current_binary` - Path to the currently running binary
282    /// * `rollback_dir` - Directory to store backup for rollback
283    ///
284    /// # Errors
285    ///
286    /// Returns an error only if both the upgrade AND rollback fail (critical).
287    pub async fn perform_upgrade(
288        &self,
289        info: &UpgradeInfo,
290        current_binary: &Path,
291        rollback_dir: &Path,
292    ) -> Result<UpgradeResult> {
293        // Auto-upgrade on Windows is not supported yet due to running-binary locks.
294        // We fail closed with an explicit reason rather than attempting a broken replace.
295        if !Self::auto_upgrade_supported() {
296            warn!(
297                "Auto-upgrade is not supported on this platform; refusing upgrade to {}",
298                info.version
299            );
300            return Ok(UpgradeResult::RolledBack {
301                reason: "Auto-upgrade not supported on this platform".to_string(),
302            });
303        }
304
305        // 1. Validate upgrade
306        self.validate_upgrade(info)?;
307
308        // 2. Create backup
309        self.create_backup(current_binary, rollback_dir)?;
310
311        // 3. Download new binary and signature to temp directory
312        let temp_dir = Self::create_tempdir_in_target_dir(current_binary)?;
313        let new_binary = temp_dir.path().join("new_binary");
314        let sig_path = temp_dir.path().join("signature");
315
316        if let Err(e) = self.download(&info.download_url, &new_binary).await {
317            warn!("Download failed: {e}");
318            return Ok(UpgradeResult::RolledBack {
319                reason: format!("Download failed: {e}"),
320            });
321        }
322
323        if let Err(e) = self.download(&info.signature_url, &sig_path).await {
324            warn!("Signature download failed: {e}");
325            return Ok(UpgradeResult::RolledBack {
326                reason: format!("Signature download failed: {e}"),
327            });
328        }
329
330        // 4. Verify signature
331        if let Err(e) = signature::verify_from_file(&new_binary, &sig_path) {
332            warn!("Signature verification failed: {e}");
333            return Ok(UpgradeResult::RolledBack {
334                reason: format!("Signature verification failed: {e}"),
335            });
336        }
337
338        // 5. Atomic replacement
339        if let Err(e) = self.atomic_replace(&new_binary, current_binary) {
340            warn!("Replacement failed, rolling back: {e}");
341            if let Err(restore_err) = self.restore_from_backup(current_binary, rollback_dir) {
342                return Err(Error::Upgrade(format!(
343                    "Critical: replacement failed ({e}) AND rollback failed ({restore_err})"
344                )));
345            }
346            return Ok(UpgradeResult::RolledBack {
347                reason: format!("Replacement failed: {e}"),
348            });
349        }
350
351        info!("Successfully upgraded to version {}", info.version);
352        Ok(UpgradeResult::Success {
353            version: info.version.clone(),
354            exit_code: 0,
355        })
356    }
357
358    /// Whether the current platform supports in-place auto-upgrade.
359    ///
360    /// Supported on Unix (via `exec()`) and Windows (via `self-replace` crate).
361    const fn auto_upgrade_supported() -> bool {
362        true
363    }
364}
365
366impl Default for Upgrader {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372/// Legacy function for backward compatibility.
373///
374/// # Errors
375///
376/// Returns an error if the upgrade fails and rollback is not possible.
377pub async fn perform_upgrade(
378    info: &UpgradeInfo,
379    current_binary: &Path,
380    rollback_dir: &Path,
381) -> Result<UpgradeResult> {
382    Upgrader::new()
383        .perform_upgrade(info, current_binary, rollback_dir)
384        .await
385}
386
387#[cfg(test)]
388#[allow(
389    clippy::unwrap_used,
390    clippy::expect_used,
391    clippy::doc_markdown,
392    clippy::cast_possible_truncation,
393    clippy::cast_sign_loss,
394    clippy::case_sensitive_file_extension_comparisons
395)]
396mod tests {
397    use super::*;
398    use tempfile::TempDir;
399
400    /// Test 1: Backup creation
401    #[test]
402    fn test_backup_created() {
403        let temp = TempDir::new().unwrap();
404        let current = temp.path().join("current");
405        let rollback_dir = temp.path().join("rollback");
406        fs::create_dir(&rollback_dir).unwrap();
407
408        let original_content = b"old binary content";
409        fs::write(&current, original_content).unwrap();
410
411        let upgrader = Upgrader::new();
412        upgrader.create_backup(&current, &rollback_dir).unwrap();
413
414        let backup_path = rollback_dir.join("current.backup");
415        assert!(backup_path.exists(), "Backup file should exist");
416        assert_eq!(
417            fs::read(&backup_path).unwrap(),
418            original_content,
419            "Backup content should match"
420        );
421    }
422
423    /// Test 2: Restore from backup
424    #[test]
425    fn test_restore_from_backup() {
426        let temp = TempDir::new().unwrap();
427        let current = temp.path().join("binary");
428        let rollback_dir = temp.path().join("rollback");
429        fs::create_dir(&rollback_dir).unwrap();
430
431        let original = b"original content";
432        fs::write(&current, original).unwrap();
433
434        let upgrader = Upgrader::new();
435        upgrader.create_backup(&current, &rollback_dir).unwrap();
436
437        // Simulate corruption
438        fs::write(&current, b"corrupted content").unwrap();
439
440        // Restore
441        upgrader
442            .restore_from_backup(&current, &rollback_dir)
443            .unwrap();
444
445        assert_eq!(fs::read(&current).unwrap(), original);
446    }
447
448    /// Test 3: Atomic replacement
449    #[test]
450    fn test_atomic_replacement() {
451        let temp = TempDir::new().unwrap();
452        let current = temp.path().join("binary");
453        let new_binary = temp.path().join("new_binary");
454
455        fs::write(&current, b"old").unwrap();
456        fs::write(&new_binary, b"new").unwrap();
457
458        let upgrader = Upgrader::new();
459        upgrader.atomic_replace(&new_binary, &current).unwrap();
460
461        assert_eq!(fs::read(&current).unwrap(), b"new");
462        assert!(!new_binary.exists(), "Source should be moved, not copied");
463    }
464
465    /// Test 4: Downgrade prevention
466    #[test]
467    fn test_downgrade_prevention() {
468        let current_version = Version::new(1, 1, 0);
469        let older_version = Version::new(1, 0, 0);
470
471        let upgrader = Upgrader::with_version(current_version);
472
473        let info = UpgradeInfo {
474            version: older_version,
475            download_url: "test".to_string(),
476            signature_url: "test.sig".to_string(),
477            release_notes: "Old".to_string(),
478        };
479
480        let result = upgrader.validate_upgrade(&info);
481        assert!(result.is_err());
482        let err_msg = result.unwrap_err().to_string();
483        assert!(
484            err_msg.contains("downgrade") || err_msg.contains("Cannot"),
485            "Error should mention downgrade prevention: {err_msg}"
486        );
487    }
488
489    /// Test 5: Same version prevention
490    #[test]
491    fn test_same_version_prevention() {
492        let version = Version::new(1, 0, 0);
493        let upgrader = Upgrader::with_version(version.clone());
494
495        let info = UpgradeInfo {
496            version,
497            download_url: "test".to_string(),
498            signature_url: "test.sig".to_string(),
499            release_notes: "Same".to_string(),
500        };
501
502        let result = upgrader.validate_upgrade(&info);
503        assert!(result.is_err(), "Same version should be rejected");
504    }
505
506    /// Test 6: Upgrade validation passes for newer version
507    #[test]
508    fn test_upgrade_validation_passes() {
509        let upgrader = Upgrader::with_version(Version::new(1, 0, 0));
510
511        let info = UpgradeInfo {
512            version: Version::new(1, 1, 0),
513            download_url: "test".to_string(),
514            signature_url: "test.sig".to_string(),
515            release_notes: "New".to_string(),
516        };
517
518        let result = upgrader.validate_upgrade(&info);
519        assert!(result.is_ok(), "Newer version should be accepted");
520    }
521
522    /// Test 7: Restore fails without backup
523    #[test]
524    fn test_restore_fails_without_backup() {
525        let temp = TempDir::new().unwrap();
526        let current = temp.path().join("binary");
527        let rollback_dir = temp.path().join("rollback");
528        fs::create_dir(&rollback_dir).unwrap();
529
530        fs::write(&current, b"content").unwrap();
531
532        let upgrader = Upgrader::new();
533        let result = upgrader.restore_from_backup(&current, &rollback_dir);
534
535        assert!(result.is_err());
536        assert!(result.unwrap_err().to_string().contains("No backup"));
537    }
538
539    /// Test 8: Permissions preserved on Unix
540    #[cfg(unix)]
541    #[test]
542    fn test_permissions_preserved() {
543        use std::os::unix::fs::PermissionsExt;
544
545        let temp = TempDir::new().unwrap();
546        let current = temp.path().join("binary");
547        let new_binary = temp.path().join("new");
548
549        fs::write(&current, b"old").unwrap();
550        fs::write(&new_binary, b"new").unwrap();
551
552        // Set executable permissions on original
553        let mut perms = fs::metadata(&current).unwrap().permissions();
554        perms.set_mode(0o755);
555        fs::set_permissions(&current, perms).unwrap();
556
557        let upgrader = Upgrader::new();
558        upgrader.atomic_replace(&new_binary, &current).unwrap();
559
560        let new_perms = fs::metadata(&current).unwrap().permissions();
561        assert_eq!(
562            new_perms.mode() & 0o777,
563            0o755,
564            "Permissions should be preserved"
565        );
566    }
567
568    /// Test 9: Current version getter
569    #[test]
570    fn test_current_version_getter() {
571        let version = Version::new(2, 3, 4);
572        let upgrader = Upgrader::with_version(version.clone());
573        assert_eq!(*upgrader.current_version(), version);
574    }
575
576    /// Test 10: Default implementation
577    #[test]
578    fn test_default_impl() {
579        let upgrader = Upgrader::default();
580        // Should not panic and should have a valid version
581        assert!(!upgrader.current_version().to_string().is_empty());
582    }
583
584    /// Test 11: Backup with special characters in filename
585    #[test]
586    fn test_backup_special_filename() {
587        let temp = TempDir::new().unwrap();
588        let current = temp.path().join("ant-node-v1.0.0");
589        let rollback_dir = temp.path().join("rollback");
590        fs::create_dir(&rollback_dir).unwrap();
591
592        fs::write(&current, b"content").unwrap();
593
594        let upgrader = Upgrader::new();
595        let result = upgrader.create_backup(&current, &rollback_dir);
596        assert!(result.is_ok());
597
598        let backup_path = rollback_dir.join("ant-node-v1.0.0.backup");
599        assert!(backup_path.exists());
600    }
601
602    /// Test 12: UpgradeInfo construction
603    #[test]
604    fn test_upgrade_info() {
605        let info = UpgradeInfo {
606            version: Version::new(1, 2, 3),
607            download_url: "https://example.com/binary".to_string(),
608            signature_url: "https://example.com/binary.sig".to_string(),
609            release_notes: "Bug fixes and improvements".to_string(),
610        };
611
612        assert_eq!(info.version, Version::new(1, 2, 3));
613        assert!(info.download_url.contains("example.com"));
614        assert!(info.signature_url.ends_with(".sig"));
615    }
616
617    /// Test 13: UpgradeResult variants
618    #[test]
619    fn test_upgrade_result_variants() {
620        let success = UpgradeResult::Success {
621            version: Version::new(1, 0, 0),
622            exit_code: 0,
623        };
624        assert!(matches!(success, UpgradeResult::Success { .. }));
625
626        let rolled_back = UpgradeResult::RolledBack {
627            reason: "Test failure".to_string(),
628        };
629        assert!(matches!(rolled_back, UpgradeResult::RolledBack { .. }));
630
631        let no_upgrade = UpgradeResult::NoUpgrade;
632        assert!(matches!(no_upgrade, UpgradeResult::NoUpgrade));
633    }
634
635    /// Test 14: Large file backup
636    #[test]
637    fn test_large_file_backup() {
638        let temp = TempDir::new().unwrap();
639        let current = temp.path().join("large_binary");
640        let rollback_dir = temp.path().join("rollback");
641        fs::create_dir(&rollback_dir).unwrap();
642
643        // Create 1MB file
644        let large_content: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
645        fs::write(&current, &large_content).unwrap();
646
647        let upgrader = Upgrader::new();
648        upgrader.create_backup(&current, &rollback_dir).unwrap();
649
650        let backup_path = rollback_dir.join("large_binary.backup");
651        assert_eq!(fs::read(&backup_path).unwrap(), large_content);
652    }
653
654    /// Test 15: Backup directory doesn't exist
655    #[test]
656    fn test_backup_nonexistent_rollback_dir() {
657        let temp = TempDir::new().unwrap();
658        let current = temp.path().join("binary");
659        let rollback_dir = temp.path().join("nonexistent");
660
661        fs::write(&current, b"content").unwrap();
662
663        let upgrader = Upgrader::new();
664        let result = upgrader.create_backup(&current, &rollback_dir);
665
666        assert!(result.is_err(), "Should fail if rollback dir doesn't exist");
667    }
668
669    /// Test 16: Tempdir for upgrades is created in target directory.
670    #[test]
671    fn test_tempdir_in_target_dir() {
672        let temp = TempDir::new().unwrap();
673        let current = temp.path().join("binary");
674        fs::write(&current, b"content").unwrap();
675
676        let tempdir = Upgrader::create_tempdir_in_target_dir(&current).unwrap();
677
678        assert_eq!(
679            tempdir.path().parent().unwrap(),
680            temp.path(),
681            "Upgrade tempdir should be in same dir as target"
682        );
683    }
684
685    /// Test 17: Enforce max binary size rejects huge downloads.
686    #[test]
687    fn test_enforce_max_binary_size_rejects_large() {
688        let too_large = MAX_BINARY_SIZE_BYTES + 1;
689        let result = Upgrader::enforce_max_binary_size(too_large);
690        assert!(result.is_err());
691    }
692
693    /// Test 18: Enforce max binary size accepts reasonable downloads.
694    #[test]
695    fn test_enforce_max_binary_size_accepts_small() {
696        let result = Upgrader::enforce_max_binary_size(1024);
697        assert!(result.is_ok());
698    }
699
700    #[test]
701    fn test_auto_upgrade_supported_on_all_platforms() {
702        assert!(Upgrader::auto_upgrade_supported());
703    }
704}