1mod 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
29const MAX_BINARY_SIZE_BYTES: usize = 200 * 1024 * 1024;
34
35#[derive(Debug, Clone)]
37pub struct UpgradeInfo {
38 pub version: Version,
40 pub download_url: String,
42 pub signature_url: String,
44 pub release_notes: String,
46}
47
48#[derive(Debug)]
50pub enum UpgradeResult {
51 Success {
53 version: Version,
55 },
56 RolledBack {
58 reason: String,
60 },
61 NoUpgrade,
63}
64
65pub struct Upgrader {
75 current_version: Version,
77 client: reqwest::Client,
79}
80
81impl Upgrader {
82 #[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 #[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 #[must_use]
106 pub fn current_version(&self) -> &Version {
107 &self.current_version
108 }
109
110 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 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 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 pub fn atomic_replace(&self, new_binary: &Path, target: &Path) -> Result<()> {
186 #[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 fs::rename(new_binary, target)?;
197 debug!("Atomic replacement complete");
198 Ok(())
199 }
200
201 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 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 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 pub async fn perform_upgrade(
279 &self,
280 info: &UpgradeInfo,
281 current_binary: &Path,
282 rollback_dir: &Path,
283 ) -> Result<UpgradeResult> {
284 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 self.validate_upgrade(info)?;
298
299 self.create_backup(current_binary, rollback_dir)?;
301
302 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 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 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 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
362pub 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]
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(¤t, original_content).unwrap();
400
401 let upgrader = Upgrader::new();
402 upgrader.create_backup(¤t, &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]
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(¤t, original).unwrap();
423
424 let upgrader = Upgrader::new();
425 upgrader.create_backup(¤t, &rollback_dir).unwrap();
426
427 fs::write(¤t, b"corrupted content").unwrap();
429
430 upgrader
432 .restore_from_backup(¤t, &rollback_dir)
433 .unwrap();
434
435 assert_eq!(fs::read(¤t).unwrap(), original);
436 }
437
438 #[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(¤t, b"old").unwrap();
446 fs::write(&new_binary, b"new").unwrap();
447
448 let upgrader = Upgrader::new();
449 upgrader.atomic_replace(&new_binary, ¤t).unwrap();
450
451 assert_eq!(fs::read(¤t).unwrap(), b"new");
452 assert!(!new_binary.exists(), "Source should be moved, not copied");
453 }
454
455 #[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]
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]
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]
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(¤t, b"content").unwrap();
521
522 let upgrader = Upgrader::new();
523 let result = upgrader.restore_from_backup(¤t, &rollback_dir);
524
525 assert!(result.is_err());
526 assert!(result.unwrap_err().to_string().contains("No backup"));
527 }
528
529 #[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(¤t, b"old").unwrap();
540 fs::write(&new_binary, b"new").unwrap();
541
542 let mut perms = fs::metadata(¤t).unwrap().permissions();
544 perms.set_mode(0o755);
545 fs::set_permissions(¤t, perms).unwrap();
546
547 let upgrader = Upgrader::new();
548 upgrader.atomic_replace(&new_binary, ¤t).unwrap();
549
550 let new_perms = fs::metadata(¤t).unwrap().permissions();
551 assert_eq!(
552 new_perms.mode() & 0o777,
553 0o755,
554 "Permissions should be preserved"
555 );
556 }
557
558 #[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]
568 fn test_default_impl() {
569 let upgrader = Upgrader::default();
570 assert!(!upgrader.current_version().to_string().is_empty());
572 }
573
574 #[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(¤t, b"content").unwrap();
583
584 let upgrader = Upgrader::new();
585 let result = upgrader.create_backup(¤t, &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]
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]
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]
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 let large_content: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
634 fs::write(¤t, &large_content).unwrap();
635
636 let upgrader = Upgrader::new();
637 upgrader.create_backup(¤t, &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]
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(¤t, b"content").unwrap();
651
652 let upgrader = Upgrader::new();
653 let result = upgrader.create_backup(¤t, &rollback_dir);
654
655 assert!(result.is_err(), "Should fail if rollback dir doesn't exist");
656 }
657
658 #[test]
660 fn test_tempdir_in_target_dir() {
661 let temp = TempDir::new().unwrap();
662 let current = temp.path().join("binary");
663 fs::write(¤t, b"content").unwrap();
664
665 let tempdir = Upgrader::create_tempdir_in_target_dir(¤t).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]
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]
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}