Skip to main content

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