saorsa_node/upgrade/
apply.rs1use 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
21const MAX_ARCHIVE_SIZE_BYTES: usize = 200 * 1024 * 1024;
23
24pub struct AutoApplyUpgrader {
26 current_version: Version,
28 client: reqwest::Client,
30}
31
32impl AutoApplyUpgrader {
33 #[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 #[must_use]
51 pub fn current_version(&self) -> &Version {
52 &self.current_version
53 }
54
55 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 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 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 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 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 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 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 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 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 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(¤t_binary, &backup_path) {
157 warn!("Backup creation failed: {e}");
158 return Ok(UpgradeResult::RolledBack {
159 reason: format!("Backup failed: {e}"),
160 });
161 }
162
163 info!("Replacing binary...");
165 if let Err(e) = Self::replace_binary(&extracted_binary, ¤t_binary) {
166 warn!("Binary replacement failed: {e}");
167 if let Err(restore_err) = fs::copy(&backup_path, ¤t_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 Self::trigger_restart(¤t_binary)?;
186
187 Ok(UpgradeResult::Success {
188 version: info.version.clone(),
189 })
190 }
191
192 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 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 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 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 #[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 fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
281 #[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 fs::rename(new_binary, target)?;
292 debug!("Binary replacement complete");
293 Ok(())
294 }
295
296 fn trigger_restart(binary_path: &Path) -> Result<()> {
301 #[cfg(unix)]
302 {
303 use std::os::unix::process::CommandExt;
304
305 let args: Vec<String> = env::args().skip(1).collect();
307
308 info!("Executing restart: {} {:?}", binary_path.display(), args);
309
310 let err = std::process::Command::new(binary_path).args(&args).exec();
312
313 Err(Error::Upgrade(format!("Failed to exec new binary: {err}")))
315 }
316
317 #[cfg(not(unix))]
318 {
319 let _ = binary_path; 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}