Skip to main content

saorsa_node/upgrade/
apply.rs

1//! Auto-apply upgrade functionality.
2//!
3//! This module handles the complete auto-upgrade workflow:
4//! 1. Download archive from GitHub releases
5//! 2. Extract the binary from tar.gz/zip
6//! 3. Verify ML-DSA signature
7//! 4. Replace running binary with backup
8//! 5. Restart the node process
9
10use crate::error::{Error, Result};
11use crate::upgrade::{signature, UpgradeInfo, UpgradeResult};
12use flate2::read::GzDecoder;
13use semver::Version;
14use std::env;
15use std::fs::{self, File};
16use std::io::Read;
17use std::path::{Path, PathBuf};
18use tar::Archive;
19use tracing::{debug, error, info, warn};
20
21/// Maximum allowed upgrade archive size (200 MiB).
22const MAX_ARCHIVE_SIZE_BYTES: usize = 200 * 1024 * 1024;
23
24/// Auto-apply upgrader with archive support.
25pub struct AutoApplyUpgrader {
26    /// Current running version.
27    current_version: Version,
28    /// HTTP client for downloads.
29    client: reqwest::Client,
30}
31
32impl AutoApplyUpgrader {
33    /// Create a new auto-apply upgrader.
34    #[must_use]
35    pub fn new() -> Self {
36        let current_version =
37            Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0));
38
39        Self {
40            current_version,
41            client: reqwest::Client::builder()
42                .user_agent(concat!("saorsa-node/", env!("CARGO_PKG_VERSION")))
43                .timeout(std::time::Duration::from_secs(300))
44                .build()
45                .unwrap_or_else(|_| reqwest::Client::new()),
46        }
47    }
48
49    /// Get the current version.
50    #[must_use]
51    pub fn current_version(&self) -> &Version {
52        &self.current_version
53    }
54
55    /// Get the path to the currently running binary.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the binary path cannot be determined.
60    pub fn current_binary_path() -> Result<PathBuf> {
61        env::current_exe().map_err(|e| Error::Upgrade(format!("Cannot determine binary path: {e}")))
62    }
63
64    /// Perform the complete auto-apply upgrade workflow.
65    ///
66    /// # Arguments
67    ///
68    /// * `info` - Upgrade information from the monitor
69    ///
70    /// # Returns
71    ///
72    /// Returns `UpgradeResult::Success` and triggers a restart on success.
73    /// Returns `UpgradeResult::RolledBack` if any step fails.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error only for critical failures where rollback also fails.
78    pub async fn apply_upgrade(&self, info: &UpgradeInfo) -> Result<UpgradeResult> {
79        info!(
80            "Starting auto-apply upgrade from {} to {}",
81            self.current_version, info.version
82        );
83
84        // Validate upgrade (prevent downgrade)
85        if info.version <= self.current_version {
86            warn!(
87                "Ignoring downgrade attempt: {} -> {}",
88                self.current_version, info.version
89            );
90            return Ok(UpgradeResult::NoUpgrade);
91        }
92
93        // Get current binary path
94        let current_binary = Self::current_binary_path()?;
95        let binary_dir = current_binary
96            .parent()
97            .ok_or_else(|| Error::Upgrade("Cannot determine binary directory".to_string()))?;
98
99        // Create temp directory for upgrade
100        let temp_dir = tempfile::Builder::new()
101            .prefix("saorsa-upgrade-")
102            .tempdir_in(binary_dir)
103            .map_err(|e| Error::Upgrade(format!("Failed to create temp dir: {e}")))?;
104
105        let archive_path = temp_dir.path().join("archive");
106        let sig_path = temp_dir.path().join("signature");
107
108        // Step 1: Download archive
109        info!("Downloading upgrade archive...");
110        if let Err(e) = self.download(&info.download_url, &archive_path).await {
111            warn!("Archive download failed: {e}");
112            return Ok(UpgradeResult::RolledBack {
113                reason: format!("Download failed: {e}"),
114            });
115        }
116
117        // Step 2: Download signature
118        info!("Downloading signature...");
119        if let Err(e) = self.download(&info.signature_url, &sig_path).await {
120            warn!("Signature download failed: {e}");
121            return Ok(UpgradeResult::RolledBack {
122                reason: format!("Signature download failed: {e}"),
123            });
124        }
125
126        // Step 3: Verify signature on archive BEFORE extraction (security: verify before unpacking)
127        info!("Verifying ML-DSA signature on archive...");
128        if let Err(e) = signature::verify_from_file(&archive_path, &sig_path) {
129            warn!("Signature verification failed: {e}");
130            return Ok(UpgradeResult::RolledBack {
131                reason: format!("Signature verification failed: {e}"),
132            });
133        }
134        info!("Archive signature verified successfully");
135
136        // Step 4: Extract binary from verified archive
137        info!("Extracting binary from archive...");
138        let extracted_binary = match Self::extract_binary(&archive_path, temp_dir.path()) {
139            Ok(path) => path,
140            Err(e) => {
141                warn!("Extraction failed: {e}");
142                return Ok(UpgradeResult::RolledBack {
143                    reason: format!("Extraction failed: {e}"),
144                });
145            }
146        };
147
148        // Step 5: Create backup of current binary
149        let backup_path = binary_dir.join(format!(
150            "{}.backup",
151            current_binary
152                .file_name()
153                .map_or_else(|| "saorsa-node".into(), |s| s.to_string_lossy())
154        ));
155        info!("Creating backup at {}...", backup_path.display());
156        if let Err(e) = fs::copy(&current_binary, &backup_path) {
157            warn!("Backup creation failed: {e}");
158            return Ok(UpgradeResult::RolledBack {
159                reason: format!("Backup failed: {e}"),
160            });
161        }
162
163        // Step 6: Replace binary
164        info!("Replacing binary...");
165        if let Err(e) = Self::replace_binary(&extracted_binary, &current_binary) {
166            warn!("Binary replacement failed: {e}");
167            // Attempt rollback
168            if let Err(restore_err) = fs::copy(&backup_path, &current_binary) {
169                error!("CRITICAL: Replacement failed ({e}) AND rollback failed ({restore_err})");
170                return Err(Error::Upgrade(format!(
171                    "Critical: replacement failed ({e}) AND rollback failed ({restore_err})"
172                )));
173            }
174            return Ok(UpgradeResult::RolledBack {
175                reason: format!("Replacement failed: {e}"),
176            });
177        }
178
179        info!(
180            "Successfully upgraded to version {}! Restarting...",
181            info.version
182        );
183
184        // Step 7: Trigger restart
185        Self::trigger_restart(&current_binary)?;
186
187        Ok(UpgradeResult::Success {
188            version: info.version.clone(),
189        })
190    }
191
192    /// Download a file to the specified path.
193    async fn download(&self, url: &str, dest: &Path) -> Result<()> {
194        debug!("Downloading: {}", url);
195
196        let response = self
197            .client
198            .get(url)
199            .send()
200            .await
201            .map_err(|e| Error::Network(format!("Download failed: {e}")))?;
202
203        if !response.status().is_success() {
204            return Err(Error::Network(format!(
205                "Download returned status: {}",
206                response.status()
207            )));
208        }
209
210        let bytes = response
211            .bytes()
212            .await
213            .map_err(|e| Error::Network(format!("Failed to read response: {e}")))?;
214
215        if bytes.len() > MAX_ARCHIVE_SIZE_BYTES {
216            return Err(Error::Upgrade(format!(
217                "Downloaded file too large: {} bytes (max {})",
218                bytes.len(),
219                MAX_ARCHIVE_SIZE_BYTES
220            )));
221        }
222
223        fs::write(dest, &bytes)?;
224        debug!("Downloaded {} bytes to {}", bytes.len(), dest.display());
225        Ok(())
226    }
227
228    /// Extract the saorsa-node binary from a tar.gz archive.
229    fn extract_binary(archive_path: &Path, dest_dir: &Path) -> Result<PathBuf> {
230        let file = File::open(archive_path)?;
231        let decoder = GzDecoder::new(file);
232        let mut archive = Archive::new(decoder);
233
234        let extracted_binary = dest_dir.join("saorsa-node");
235
236        for entry in archive
237            .entries()
238            .map_err(|e| Error::Upgrade(format!("Failed to read archive: {e}")))?
239        {
240            let mut entry =
241                entry.map_err(|e| Error::Upgrade(format!("Failed to read entry: {e}")))?;
242            let path = entry
243                .path()
244                .map_err(|e| Error::Upgrade(format!("Invalid path in archive: {e}")))?;
245
246            // Look for the saorsa-node binary
247            if let Some(name) = path.file_name() {
248                let name_str = name.to_string_lossy();
249                if name_str == "saorsa-node" || name_str == "saorsa-node.exe" {
250                    debug!("Found binary in archive: {}", path.display());
251
252                    // Read and write the binary
253                    let mut contents = Vec::new();
254                    entry
255                        .read_to_end(&mut contents)
256                        .map_err(|e| Error::Upgrade(format!("Failed to read binary: {e}")))?;
257
258                    fs::write(&extracted_binary, &contents)?;
259
260                    // Make executable on Unix
261                    #[cfg(unix)]
262                    {
263                        use std::os::unix::fs::PermissionsExt;
264                        let mut perms = fs::metadata(&extracted_binary)?.permissions();
265                        perms.set_mode(0o755);
266                        fs::set_permissions(&extracted_binary, perms)?;
267                    }
268
269                    return Ok(extracted_binary);
270                }
271            }
272        }
273
274        Err(Error::Upgrade(
275            "saorsa-node binary not found in archive".to_string(),
276        ))
277    }
278
279    /// Replace the current binary with the new one.
280    fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
281        // Preserve original permissions on Unix
282        #[cfg(unix)]
283        {
284            if let Ok(meta) = fs::metadata(target) {
285                let perms = meta.permissions();
286                fs::set_permissions(new_binary, perms)?;
287            }
288        }
289
290        // Atomic rename
291        fs::rename(new_binary, target)?;
292        debug!("Binary replacement complete");
293        Ok(())
294    }
295
296    /// Trigger a restart of the node process.
297    ///
298    /// On Unix, uses `exec()` to replace the current process.
299    /// The calling code should ensure graceful shutdown before calling this.
300    fn trigger_restart(binary_path: &Path) -> Result<()> {
301        #[cfg(unix)]
302        {
303            use std::os::unix::process::CommandExt;
304
305            // Collect current args (skip the binary name)
306            let args: Vec<String> = env::args().skip(1).collect();
307
308            info!("Executing restart: {} {:?}", binary_path.display(), args);
309
310            // exec() replaces the current process
311            let err = std::process::Command::new(binary_path).args(&args).exec();
312
313            // If we get here, exec failed
314            Err(Error::Upgrade(format!("Failed to exec new binary: {err}")))
315        }
316
317        #[cfg(not(unix))]
318        {
319            // On Windows, we can't replace a running binary
320            // Just log and let the user restart manually
321            let _ = binary_path; // Suppress unused warning on Windows
322            warn!("Auto-restart not supported on this platform. Please restart manually.");
323            Ok(())
324        }
325    }
326}
327
328impl Default for AutoApplyUpgrader {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used, clippy::expect_used)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_auto_apply_upgrader_creation() {
341        let upgrader = AutoApplyUpgrader::new();
342        assert!(!upgrader.current_version().to_string().is_empty());
343    }
344
345    #[test]
346    fn test_current_binary_path() {
347        let result = AutoApplyUpgrader::current_binary_path();
348        assert!(result.is_ok());
349        let path = result.unwrap();
350        assert!(path.exists() || path.to_string_lossy().contains("test"));
351    }
352
353    #[test]
354    fn test_default_impl() {
355        let upgrader = AutoApplyUpgrader::default();
356        assert!(!upgrader.current_version().to_string().is_empty());
357    }
358}