agpm_cli/upgrade/self_updater.rs
1use anyhow::{Context, Result, bail};
2use semver::Version;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5
6/// Validate repository identifiers to prevent URL injection attacks.
7///
8/// Repository owner and name must only contain alphanumeric characters,
9/// hyphens, underscores, and dots. This prevents malicious injection of
10/// special characters that could be used to construct malicious URLs.
11///
12/// # Arguments
13///
14/// * `identifier` - The repository owner or name to validate
15///
16/// # Returns
17///
18/// `true` if the identifier is safe to use in URL construction, `false` otherwise.
19///
20/// # Examples
21///
22/// ```rust,no_run
23/// // This function is used internally by SelfUpdater::with_repo()
24/// // for repository identifier validation
25/// use agpm_cli::upgrade::SelfUpdater;
26///
27/// // Valid repository identifiers
28/// let updater = SelfUpdater::with_repo("aig787", "agpm");
29/// assert!(updater.is_ok());
30///
31/// let updater = SelfUpdater::with_repo("my-repo", "my_project");
32/// assert!(updater.is_ok());
33///
34/// // Invalid repository identifiers would fail
35/// let updater = SelfUpdater::with_repo("../evil", "repo");
36/// assert!(updater.is_err());
37/// ```
38fn validate_repo_identifier(identifier: &str) -> bool {
39 if identifier.is_empty() || identifier.len() > 100 {
40 return false;
41 }
42
43 // Only allow alphanumeric, hyphens, underscores, and dots
44 identifier.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
45 // Additional safety: prevent starting/ending with dots or hyphens
46 && !identifier.starts_with('.')
47 && !identifier.starts_with('-')
48 && !identifier.ends_with('.')
49 && !identifier.ends_with('-')
50 // Prevent consecutive dots or dot-slash combinations
51 && !identifier.contains("..")
52 && !identifier.contains("./")
53 && !identifier.contains('\\')
54}
55
56/// Validate and sanitize a file path to prevent path traversal attacks.
57///
58/// This function performs comprehensive path traversal protection by:
59/// 1. Checking for obvious traversal patterns
60/// 2. Resolving the path to its canonical form
61/// 3. Verifying the canonical path is within the expected base directory
62///
63/// # Arguments
64///
65/// * `path` - The path to validate
66/// * `base_dir` - The base directory that the path must be within
67///
68/// # Returns
69///
70/// `Ok(PathBuf)` with the validated canonical path, or an error if the path is unsafe.
71fn validate_and_sanitize_path(path: &Path, base_dir: &Path) -> Result<PathBuf> {
72 let path_str = path.to_string_lossy();
73
74 // Basic checks for obvious traversal attempts
75 if path_str.contains("..")
76 || path_str.starts_with('/')
77 || path_str.starts_with('\\')
78 || path_str.contains('\0')
79 {
80 bail!("Path contains unsafe traversal patterns: {path_str}");
81 }
82
83 // Get canonical base directory
84 let canonical_base = base_dir.canonicalize().with_context(|| {
85 format!("Failed to canonicalize base directory: {}", base_dir.display())
86 })?;
87
88 // Create the full path and canonicalize it
89 let full_path = base_dir.join(path);
90 let canonical_path = match full_path.canonicalize() {
91 Ok(p) => p,
92 Err(_) => {
93 // If canonicalize fails, the path might not exist yet
94 // Try to canonicalize the parent and append the filename
95 if let Some(parent) = full_path.parent() {
96 if let Some(filename) = full_path.file_name() {
97 match parent.canonicalize() {
98 Ok(canonical_parent) => canonical_parent.join(filename),
99 Err(_) => {
100 // If parent doesn't exist either, validate manually
101 return validate_path_components(&full_path, &canonical_base);
102 }
103 }
104 } else {
105 bail!("Invalid path structure: {}", full_path.display());
106 }
107 } else {
108 bail!("Invalid path: {}", full_path.display());
109 }
110 }
111 };
112
113 // Ensure the canonical path is within the base directory
114 if !canonical_path.starts_with(&canonical_base) {
115 bail!(
116 "Path traversal detected: {} is outside base directory {}",
117 canonical_path.display(),
118 canonical_base.display()
119 );
120 }
121
122 Ok(canonical_path)
123}
124
125/// Validate path components when canonicalization is not possible.
126fn validate_path_components(path: &Path, base_dir: &Path) -> Result<PathBuf> {
127 let mut validated_path = base_dir.to_path_buf();
128
129 for component in path.components() {
130 match component {
131 std::path::Component::Normal(name) => {
132 let name_str = name.to_string_lossy();
133 if name_str.contains('\0') || name_str == "." || name_str == ".." {
134 bail!("Invalid path component: {name_str}");
135 }
136 validated_path.push(name);
137 }
138 std::path::Component::CurDir => {
139 // Skip current directory references
140 continue;
141 }
142 std::path::Component::ParentDir => {
143 bail!("Parent directory traversal not allowed");
144 }
145 _ => {
146 bail!("Absolute path components not allowed in extraction");
147 }
148 }
149 }
150
151 Ok(validated_path)
152}
153
154/// Security policy for checksum verification during updates.
155///
156/// This enum allows configuring how strictly the updater enforces checksum
157/// verification, balancing security with usability.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum ChecksumPolicy {
160 /// Require checksum verification - fail if checksum is unavailable or invalid.
161 Required,
162 /// Warn if checksum verification fails but continue with update.
163 WarnOnFailure,
164 /// Skip checksum verification entirely (not recommended for production).
165 Skip,
166}
167
168impl Default for ChecksumPolicy {
169 fn default() -> Self {
170 // Default to warning mode for backward compatibility
171 Self::WarnOnFailure
172 }
173}
174
175/// Core self-update manager for AGPM binary upgrades.
176///
177/// `SelfUpdater` handles the entire process of checking for and installing AGPM updates
178/// from GitHub releases. It provides a safe, reliable way to upgrade the running binary
179/// with proper error handling and version management.
180///
181/// # Features
182///
183/// - **GitHub Integration**: Fetches releases from the official AGPM repository
184/// - **Version Comparison**: Uses semantic versioning for intelligent update detection
185/// - **Force Updates**: Allows forcing updates even when already on latest version
186/// - **Target Versions**: Supports upgrading to specific versions or latest
187/// - **Progress Tracking**: Shows download progress during updates
188/// - **Security**: URL validation, path traversal protection, configurable checksum verification
189///
190/// # Safety
191///
192/// The updater itself only handles the download and binary replacement. For full
193/// safety, it should be used in conjunction with [`BackupManager`](crate::upgrade::backup::BackupManager)
194/// to create backups before updates.
195///
196/// # Examples
197///
198/// ## Check for Updates
199/// ```rust,no_run
200/// use agpm_cli::upgrade::SelfUpdater;
201///
202/// # async fn example() -> anyhow::Result<()> {
203/// let updater = SelfUpdater::new();
204///
205/// if let Some(latest_version) = updater.check_for_update().await? {
206/// println!("Update available: {} -> {}",
207/// updater.current_version(), latest_version);
208/// } else {
209/// println!("Already on latest version: {}", updater.current_version());
210/// }
211/// # Ok(())
212/// # }
213/// ```
214///
215/// ## Update to Latest Version
216/// ```rust,no_run
217/// use agpm_cli::upgrade::SelfUpdater;
218///
219/// # async fn example() -> anyhow::Result<()> {
220/// let updater = SelfUpdater::new();
221///
222/// match updater.update_to_latest().await? {
223/// true => println!("Successfully updated to latest version"),
224/// false => println!("Already on latest version"),
225/// }
226/// # Ok(())
227/// # }
228/// ```
229///
230/// ## Force Update with Required Checksums
231/// ```rust,no_run
232/// use agpm_cli::upgrade::{SelfUpdater, ChecksumPolicy};
233///
234/// # async fn example() -> anyhow::Result<()> {
235/// let updater = SelfUpdater::new()
236/// .force(true)
237/// .checksum_policy(ChecksumPolicy::Required);
238///
239/// // This will update even if already on the latest version
240/// // and require checksum verification
241/// updater.update_to_latest().await?;
242/// # Ok(())
243/// # }
244/// ```
245///
246/// # Repository Configuration
247///
248/// By default, updates are fetched from `aig787/agpm` on GitHub. This is configured
249/// in the [`Default`] implementation and targets the official AGPM repository.
250///
251/// # Error Handling
252///
253/// All methods return `Result<T, anyhow::Error>` for comprehensive error handling:
254/// - Network errors during GitHub API calls
255/// - Version parsing errors for invalid semver
256/// - File system errors during binary replacement
257/// - Permission errors on locked or protected files
258/// - Security validation failures
259pub struct SelfUpdater {
260 /// GitHub repository owner (e.g., "aig787").
261 repo_owner: String,
262 /// GitHub repository name (e.g., "agpm").
263 repo_name: String,
264 /// Current version of the running binary.
265 current_version: String,
266 /// Whether to force updates even when already on latest version.
267 force: bool,
268 /// Policy for checksum verification during downloads.
269 checksum_policy: ChecksumPolicy,
270}
271
272impl Default for SelfUpdater {
273 /// Create a new `SelfUpdater` with default configuration.
274 ///
275 /// # Default Configuration
276 ///
277 /// - **Repository**: `aig787/agpm` (official AGPM repository)
278 /// - **Binary Name**: `agpm`
279 /// - **Current Version**: Detected from build-time crate version
280 /// - **Force Mode**: Disabled (respects version comparisons)
281 ///
282 /// # Examples
283 ///
284 /// ```rust,no_run
285 /// use agpm_cli::upgrade::SelfUpdater;
286 ///
287 /// let updater = SelfUpdater::default();
288 /// println!("Current version: {}", updater.current_version());
289 /// ```
290 fn default() -> Self {
291 // These are hardcoded safe values for the official repository
292 let repo_owner = "aig787".to_string();
293 let repo_name = "agpm".to_string();
294
295 // Validate even hardcoded values for extra safety
296 debug_assert!(validate_repo_identifier(&repo_owner), "Default repo_owner must be valid");
297 debug_assert!(validate_repo_identifier(&repo_name), "Default repo_name must be valid");
298
299 Self {
300 repo_owner,
301 repo_name,
302 current_version: env!("CARGO_PKG_VERSION").to_string(),
303 force: false,
304 checksum_policy: ChecksumPolicy::default(),
305 }
306 }
307}
308
309impl SelfUpdater {
310 /// Create a new `SelfUpdater` with default settings.
311 ///
312 /// This is equivalent to [`Default::default()`] but provides a more
313 /// conventional constructor-style interface.
314 ///
315 /// # Examples
316 ///
317 /// ```rust,no_run
318 /// use agpm_cli::upgrade::SelfUpdater;
319 ///
320 /// let updater = SelfUpdater::new();
321 /// assert_eq!(updater.current_version(), env!("CARGO_PKG_VERSION"));
322 /// ```
323 pub fn new() -> Self {
324 Self::default()
325 }
326
327 /// Create a new `SelfUpdater` with custom repository settings.
328 ///
329 /// This constructor allows specifying a custom GitHub repository for updates,
330 /// with security validation to prevent URL injection attacks.
331 ///
332 /// # Arguments
333 ///
334 /// * `repo_owner` - GitHub repository owner (e.g., "aig787")
335 /// * `repo_name` - GitHub repository name (e.g., "agpm")
336 ///
337 /// # Errors
338 ///
339 /// Returns an error if the repository identifiers contain invalid characters
340 /// that could be used for URL injection or other attacks.
341 ///
342 /// # Examples
343 ///
344 /// ```rust,no_run
345 /// use agpm_cli::upgrade::SelfUpdater;
346 ///
347 /// # fn example() -> anyhow::Result<()> {
348 /// // Valid repository identifiers
349 /// let updater = SelfUpdater::with_repo("aig787", "agpm")?;
350 /// let custom = SelfUpdater::with_repo("my-org", "my_fork")?;
351 ///
352 /// // This would fail due to invalid characters
353 /// // let bad = SelfUpdater::with_repo("../evil", "repo");
354 /// # Ok(())
355 /// # }
356 /// ```
357 pub fn with_repo(repo_owner: &str, repo_name: &str) -> Result<Self> {
358 if !validate_repo_identifier(repo_owner) {
359 bail!("Invalid repository owner: {repo_owner}");
360 }
361 if !validate_repo_identifier(repo_name) {
362 bail!("Invalid repository name: {repo_name}");
363 }
364
365 Ok(Self {
366 repo_owner: repo_owner.to_string(),
367 repo_name: repo_name.to_string(),
368 current_version: env!("CARGO_PKG_VERSION").to_string(),
369 force: false,
370 checksum_policy: ChecksumPolicy::default(),
371 })
372 }
373
374 /// Configure whether to force updates regardless of version comparison.
375 ///
376 /// When force mode is enabled, the updater will attempt to download and
377 /// install updates even if the current version is already the latest or
378 /// newer than the target version.
379 ///
380 /// # Use Cases
381 ///
382 /// - **Reinstalling**: Fix corrupted binary installations
383 /// - **Downgrading**: Install older versions for compatibility
384 /// - **Testing**: Verify update mechanism functionality
385 /// - **Recovery**: Restore from problematic versions
386 ///
387 /// # Arguments
388 ///
389 /// * `force` - `true` to enable force mode, `false` to respect version comparisons
390 ///
391 /// # Examples
392 ///
393 /// ```rust,no_run
394 /// use agpm_cli::upgrade::SelfUpdater;
395 ///
396 /// // Normal update (respects versions)
397 /// let updater = SelfUpdater::new();
398 ///
399 /// // Force update (ignores version comparison)
400 /// let force_updater = SelfUpdater::new().force(true);
401 /// ```
402 pub const fn force(mut self, force: bool) -> Self {
403 self.force = force;
404 self
405 }
406
407 /// Configure the checksum verification policy for downloads.
408 ///
409 /// This setting controls how the updater handles checksum verification during
410 /// binary downloads, allowing you to balance security with usability.
411 ///
412 /// # Security Implications
413 ///
414 /// - **Required**: Maximum security, but updates may fail if checksums are unavailable
415 /// - **`WarnOnFailure`**: Good balance of security and usability (default)
416 /// - **Skip**: Least secure, not recommended for production use
417 ///
418 /// # Arguments
419 ///
420 /// * `policy` - The checksum verification policy to use
421 ///
422 /// # Examples
423 ///
424 /// ```rust,no_run
425 /// use agpm_cli::upgrade::{SelfUpdater, ChecksumPolicy};
426 ///
427 /// // Require checksum verification (most secure)
428 /// let secure_updater = SelfUpdater::new()
429 /// .checksum_policy(ChecksumPolicy::Required);
430 ///
431 /// // Skip checksum verification (least secure)
432 /// let fast_updater = SelfUpdater::new()
433 /// .checksum_policy(ChecksumPolicy::Skip);
434 /// ```
435 pub const fn checksum_policy(mut self, policy: ChecksumPolicy) -> Self {
436 self.checksum_policy = policy;
437 self
438 }
439
440 /// Get the current version of the running AGPM binary.
441 ///
442 /// This version is determined at compile time from the crate's `Cargo.toml`
443 /// and represents the version of the currently executing binary.
444 ///
445 /// # Returns
446 ///
447 /// A string slice containing the semantic version (e.g., "0.3.14").
448 ///
449 /// # Examples
450 ///
451 /// ```rust,no_run
452 /// use agpm_cli::upgrade::SelfUpdater;
453 ///
454 /// let updater = SelfUpdater::new();
455 /// println!("Current AGPM version: {}", updater.current_version());
456 /// ```
457 pub fn current_version(&self) -> &str {
458 &self.current_version
459 }
460
461 /// Construct a validated GitHub API URL.
462 ///
463 /// This method provides secure URL construction by validating repository
464 /// identifiers before use, preventing URL injection attacks.
465 ///
466 /// # Arguments
467 ///
468 /// * `endpoint` - The API endpoint path (e.g., "releases/latest")
469 ///
470 /// # Returns
471 ///
472 /// A validated GitHub API URL.
473 ///
474 /// # Panics
475 ///
476 /// Panics in debug builds if repository identifiers are invalid. This should
477 /// never happen in practice due to validation in constructors.
478 fn build_github_api_url(&self, endpoint: &str) -> String {
479 // Re-validate repository identifiers for extra safety
480 debug_assert!(
481 validate_repo_identifier(&self.repo_owner),
482 "Repository owner should be validated: {}",
483 self.repo_owner
484 );
485 debug_assert!(
486 validate_repo_identifier(&self.repo_name),
487 "Repository name should be validated: {}",
488 self.repo_name
489 );
490
491 format!("https://api.github.com/repos/{}/{}/{}", self.repo_owner, self.repo_name, endpoint)
492 }
493
494 /// Construct a validated GitHub releases download URL.
495 ///
496 /// This method provides secure URL construction for downloading release assets.
497 ///
498 /// # Arguments
499 ///
500 /// * `version` - The release version
501 /// * `filename` - The asset filename
502 ///
503 /// # Returns
504 ///
505 /// A validated GitHub releases download URL.
506 fn build_github_download_url(&self, version: &str, filename: &str) -> String {
507 // Re-validate repository identifiers for extra safety
508 debug_assert!(
509 validate_repo_identifier(&self.repo_owner),
510 "Repository owner should be validated: {}",
511 self.repo_owner
512 );
513 debug_assert!(
514 validate_repo_identifier(&self.repo_name),
515 "Repository name should be validated: {}",
516 self.repo_name
517 );
518
519 format!(
520 "https://github.com/{}/{}/releases/download/v{}/{}",
521 self.repo_owner, self.repo_name, version, filename
522 )
523 }
524
525 /// Check if a newer version is available on GitHub.
526 ///
527 /// Queries the GitHub API to fetch the latest release information and
528 /// compares it with the current version using semantic versioning rules.
529 /// This method does not download or install anything.
530 ///
531 /// # Returns
532 ///
533 /// - `Ok(Some(version))` - A newer version is available
534 /// - `Ok(None)` - Already on the latest version or no releases found
535 /// - `Err(error)` - Network error, API failure, or version parsing error
536 ///
537 /// # Errors
538 ///
539 /// This method can fail if:
540 /// - Network connectivity issues prevent GitHub API access
541 /// - GitHub API rate limiting is exceeded
542 /// - Release version tags are not valid semantic versions
543 /// - Repository is not found or access is denied
544 ///
545 /// # Examples
546 ///
547 /// ```rust,no_run
548 /// use agpm_cli::upgrade::SelfUpdater;
549 ///
550 /// # async fn example() -> anyhow::Result<()> {
551 /// let updater = SelfUpdater::new();
552 ///
553 /// match updater.check_for_update().await? {
554 /// Some(latest) => {
555 /// println!("Update available: {} -> {}",
556 /// updater.current_version(), latest);
557 /// }
558 /// None => {
559 /// println!("Already on latest version: {}",
560 /// updater.current_version());
561 /// }
562 /// }
563 /// # Ok(())
564 /// # }
565 /// ```
566 pub async fn check_for_update(&self) -> Result<Option<String>> {
567 debug!("Checking for updates from {}/{}", self.repo_owner, self.repo_name);
568
569 let url = self.build_github_api_url("releases/latest");
570
571 let client = reqwest::Client::new();
572 let response = client
573 .get(&url)
574 .header("User-Agent", "agpm")
575 .send()
576 .await
577 .context("Failed to fetch release information")?;
578
579 if !response.status().is_success() {
580 if response.status() == 404 {
581 warn!("No releases found");
582 return Ok(None);
583 }
584 bail!("GitHub API error: {}", response.status());
585 }
586
587 let release: serde_json::Value = response.json().await?;
588 let latest_version = release["tag_name"]
589 .as_str()
590 .ok_or_else(|| anyhow::anyhow!("Release missing tag_name"))?
591 .trim_start_matches('v');
592
593 debug!("Latest version: {}", latest_version);
594
595 let current =
596 Version::parse(&self.current_version).context("Failed to parse current version")?;
597 let latest = Version::parse(latest_version).context("Failed to parse latest version")?;
598
599 if latest > current {
600 info!("Update available: {} -> {}", self.current_version, latest_version);
601 Ok(Some(latest_version.to_string()))
602 } else {
603 debug!("Already on latest version");
604 Ok(None)
605 }
606 }
607
608 /// Update the AGPM binary to a specific version or latest.
609 ///
610 /// Downloads and installs the specified version from GitHub releases,
611 /// replacing the current binary. This is the core update method used by
612 /// both version-specific and latest update operations.
613 ///
614 /// # Arguments
615 ///
616 /// * `target_version` - Specific version to install (e.g., "0.4.0"), or `None` for latest
617 ///
618 /// # Returns
619 ///
620 /// - `Ok(true)` - Successfully updated to the target version
621 /// - `Ok(false)` - Already on target version (no update needed)
622 /// - `Err(error)` - Update failed due to download, permission, or file system error
623 ///
624 /// # Force Mode Behavior
625 ///
626 /// When force mode is enabled via [`force()`](Self::force):
627 /// - Bypasses version comparison checks
628 /// - Downloads and installs even if already on target version
629 /// - Useful for reinstalling or recovery scenarios
630 ///
631 /// # Errors
632 ///
633 /// This method can fail if:
634 /// - Network issues prevent downloading the release
635 /// - Insufficient permissions to replace the binary
636 /// - Target version doesn't exist or has no binary assets
637 /// - File system errors during binary replacement
638 /// - The downloaded binary is corrupted or invalid
639 ///
640 /// # Platform Considerations
641 ///
642 /// - **Windows**: May require retries due to file locking
643 /// - **Unix**: Preserves executable permissions
644 /// - **macOS**: Works with both Intel and Apple Silicon binaries
645 ///
646 /// # Examples
647 ///
648 /// ```rust,no_run
649 /// use agpm_cli::upgrade::SelfUpdater;
650 ///
651 /// # async fn example() -> anyhow::Result<()> {
652 /// let updater = SelfUpdater::new();
653 ///
654 /// // Update to latest version
655 /// if updater.update(None).await? {
656 /// println!("Successfully updated!");
657 /// } else {
658 /// println!("Already on latest version");
659 /// }
660 ///
661 /// // Update to specific version
662 /// if updater.update(Some("0.4.0")).await? {
663 /// println!("Successfully updated to v0.4.0!");
664 /// }
665 /// # Ok(())
666 /// # }
667 /// ```
668 pub async fn update(&self, target_version: Option<&str>) -> Result<bool> {
669 info!("Starting self-update process");
670
671 // Custom implementation to handle tar.xz archives
672 // First, determine the target version
673 let target_version = if let Some(v) = target_version {
674 v.trim_start_matches('v').to_string()
675 } else {
676 // Get latest version from GitHub API
677 let url = self.build_github_api_url("releases/latest");
678
679 let client = reqwest::Client::new();
680 let response = client
681 .get(&url)
682 .header("User-Agent", "agpm")
683 .send()
684 .await
685 .context("Failed to fetch release information")?;
686
687 if !response.status().is_success() {
688 bail!("Failed to get latest release: HTTP {}", response.status());
689 }
690
691 let release: serde_json::Value = response.json().await?;
692 release["tag_name"]
693 .as_str()
694 .ok_or_else(|| anyhow::anyhow!("Release missing tag_name"))?
695 .trim_start_matches('v')
696 .to_string()
697 };
698
699 // Check if we need to update
700 let current = Version::parse(&self.current_version)?;
701 let target = Version::parse(&target_version)?;
702
703 if !self.force && current >= target {
704 info!("Already on version {} (target: {})", current, target);
705 return Ok(false);
706 }
707
708 // Download the appropriate archive for this platform
709 let archive_url = self.get_archive_url(&target_version)?;
710 info!("Downloading from {}", archive_url);
711
712 // Download to temp file
713 let temp_dir = tempfile::tempdir()?;
714 let archive_path = temp_dir.path().join("agpm-archive");
715
716 self.download_file(&archive_url, &archive_path).await?;
717
718 // Extract the binary from the archive
719 let extracted_binary = self.extract_binary(&archive_path, temp_dir.path()).await?;
720
721 // Replace the current binary
722 self.replace_binary(&extracted_binary).await?;
723
724 info!("Successfully updated to version {}", target_version);
725 Ok(true)
726 }
727
728 /// Get the download URL for the archive based on platform
729 fn get_archive_url(&self, version: &str) -> Result<String> {
730 let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
731 ("macos", "aarch64") => "aarch64-apple-darwin",
732 ("macos", "x86_64") => "x86_64-apple-darwin",
733 ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
734 ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
735 ("windows", "x86_64") => "x86_64-pc-windows-msvc",
736 ("windows", "aarch64") => "aarch64-pc-windows-msvc",
737 (os, arch) => bail!("Unsupported platform: {os}-{arch}"),
738 };
739
740 let extension = if std::env::consts::OS == "windows" {
741 "zip"
742 } else {
743 "tar.xz"
744 };
745
746 let filename = format!("agpm-{platform}.{extension}");
747 Ok(self.build_github_download_url(version, &filename))
748 }
749
750 /// Download a file from URL to destination with optional checksum verification
751 async fn download_file(&self, url: &str, dest: &std::path::Path) -> Result<()> {
752 use tokio::io::AsyncWriteExt;
753
754 // Configure client with timeout
755 let client = reqwest::Client::builder()
756 .timeout(std::time::Duration::from_secs(300)) // 5 minute timeout
757 .build()?;
758
759 let mut retries = 3;
760 let mut delay = std::time::Duration::from_secs(1);
761
762 loop {
763 match client.get(url).header("User-Agent", "agpm").send().await {
764 Ok(response) => {
765 if !response.status().is_success() {
766 if retries > 0 && response.status().is_server_error() {
767 warn!("Server error {}, retrying in {:?}...", response.status(), delay);
768 tokio::time::sleep(delay).await;
769 delay *= 2; // Exponential backoff
770 retries -= 1;
771 continue;
772 }
773 bail!("Failed to download: HTTP {}", response.status());
774 }
775
776 // Check content length for size limits (max 100MB)
777 if let Some(content_length) = response.content_length()
778 && content_length > 100 * 1024 * 1024
779 {
780 bail!("Archive too large: {content_length} bytes (max 100MB)");
781 }
782
783 let bytes = response.bytes().await?;
784
785 // Write using async I/O
786 let mut file = tokio::fs::File::create(dest).await?;
787 file.write_all(&bytes).await?;
788 file.sync_all().await?;
789
790 // Verify checksum based on policy
791 match self.checksum_policy {
792 ChecksumPolicy::Required => {
793 if let Some(checksum_url) = self.get_checksum_url(url) {
794 self.verify_checksum(&checksum_url, dest, &bytes).await?;
795 } else {
796 bail!(
797 "Checksum verification required but no checksum available for URL: {url}"
798 );
799 }
800 }
801 ChecksumPolicy::WarnOnFailure => {
802 if let Some(checksum_url) = self.get_checksum_url(url) {
803 if let Err(e) =
804 self.verify_checksum(&checksum_url, dest, &bytes).await
805 {
806 warn!("Checksum verification failed, but continuing: {}", e);
807 }
808 } else {
809 warn!("No checksum available for verification: {}", url);
810 }
811 }
812 ChecksumPolicy::Skip => {
813 debug!("Skipping checksum verification as configured");
814 }
815 }
816
817 return Ok(());
818 }
819 Err(e) if retries > 0 => {
820 warn!("Download failed: {}, retrying in {:?}...", e, delay);
821 tokio::time::sleep(delay).await;
822 delay *= 2; // Exponential backoff
823 retries -= 1;
824 }
825 Err(e) => bail!("Failed to download after retries: {e}"),
826 }
827 }
828 }
829
830 /// Get checksum URL for a given download URL
831 fn get_checksum_url(&self, url: &str) -> Option<String> {
832 // GitHub releases have .sha256 files
833 if url.contains("github.com") && !url.ends_with(".sha256") {
834 Some(format!("{url}.sha256"))
835 } else {
836 None
837 }
838 }
839
840 /// Verify SHA256 checksum of downloaded file
841 async fn verify_checksum(
842 &self,
843 checksum_url: &str,
844 file_path: &std::path::Path,
845 content: &[u8],
846 ) -> Result<()> {
847 use sha2::{Digest, Sha256};
848
849 // Download checksum file
850 let client =
851 reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)).build()?;
852
853 let response = client
854 .get(checksum_url)
855 .header("User-Agent", "agpm")
856 .send()
857 .await
858 .context("Failed to download checksum file")?;
859
860 if !response.status().is_success() {
861 bail!("Failed to download checksum: HTTP {}", response.status());
862 }
863
864 let checksum_text =
865 response.text().await.context("Failed to read checksum file content")?;
866
867 // Parse checksum (format: "<hash> <filename>" or just "<hash>")
868 let expected_checksum = checksum_text
869 .split_whitespace()
870 .next()
871 .ok_or_else(|| anyhow::anyhow!("Invalid checksum format: empty file"))?;
872
873 // Validate checksum format (should be 64 hex characters for SHA256)
874 if expected_checksum.len() != 64
875 || !expected_checksum.chars().all(|c| c.is_ascii_hexdigit())
876 {
877 bail!("Invalid SHA256 checksum format: {expected_checksum}");
878 }
879
880 // Calculate actual checksum
881 let mut hasher = Sha256::new();
882 hasher.update(content);
883 let actual_checksum = format!("{:x}", hasher.finalize());
884
885 if expected_checksum.to_lowercase() != actual_checksum {
886 // Delete the potentially corrupted file
887 let _ = tokio::fs::remove_file(file_path).await;
888 bail!(
889 "Checksum verification failed! Expected: {expected_checksum}, Got: {actual_checksum}. File may be corrupted or tampered with."
890 );
891 }
892
893 info!("Checksum verified successfully (SHA256: {})", &actual_checksum[..16]);
894 Ok(())
895 }
896
897 /// Extract the binary from the downloaded archive with security checks
898 async fn extract_binary(
899 &self,
900 archive_path: &std::path::Path,
901 temp_dir: &std::path::Path,
902 ) -> Result<std::path::PathBuf> {
903 let binary_name = if std::env::consts::OS == "windows" {
904 "agpm.exe"
905 } else {
906 "agpm"
907 };
908
909 if archive_path.to_string_lossy().ends_with(".zip") {
910 // Handle zip archives for Windows
911 let archive_data = tokio::fs::read(archive_path).await?;
912 let cursor = std::io::Cursor::new(archive_data);
913 let mut archive = zip::ZipArchive::new(cursor)?;
914
915 // Check for zip bombs - total uncompressed size
916 let total_size: u64 = (0..archive.len())
917 .map(|i| archive.by_index(i).map(|f| f.size()).unwrap_or(0))
918 .sum();
919
920 if total_size > 500 * 1024 * 1024 {
921 // 500MB limit
922 bail!("Archive uncompressed size too large: {total_size} bytes");
923 }
924
925 for i in 0..archive.len() {
926 let file = archive.by_index(i)?;
927 let file_name = file.name();
928
929 if file_name.ends_with(&binary_name) {
930 // Use comprehensive path validation
931 let file_path = Path::new(file_name);
932 if let Err(e) = validate_and_sanitize_path(file_path, temp_dir) {
933 warn!("Skipping malicious path {}: {}", file_name, e);
934 continue;
935 }
936
937 // Additional check: ensure the file is in a reasonable location
938 let path_components: Vec<&str> = file_name.split(&['/', '\\'][..]).collect();
939 if path_components.len() > 3 {
940 warn!("Binary nested too deep in archive: {}", file_name);
941 continue;
942 }
943
944 // Use the validated path, but always extract to the binary name in temp_dir for consistency
945 let output_path = temp_dir.join(binary_name);
946
947 // Read and write with size limit
948 use std::io::Read;
949 let mut content = Vec::new();
950 let size = file
951 .take(100 * 1024 * 1024) // 100MB limit
952 .read_to_end(&mut content)?;
953
954 if size >= 100 * 1024 * 1024 {
955 bail!("Binary file too large in archive");
956 }
957
958 // Write using async I/O
959 tokio::fs::write(&output_path, content).await?;
960 return Ok(output_path);
961 }
962 }
963 bail!("Binary not found in archive");
964 }
965
966 // Handle tar.xz archives for Unix
967 // Use system tar command as it's more reliable for xz
968 let output = tokio::process::Command::new("tar")
969 .args(["-xf", &archive_path.to_string_lossy(), "-C", &temp_dir.to_string_lossy()])
970 .output()
971 .await?;
972
973 if !output.status.success() {
974 bail!("Failed to extract archive: {}", String::from_utf8_lossy(&output.stderr));
975 }
976
977 // Look for the binary in extracted directory structure
978 // The archive contains a directory with the binary inside
979 let mut entries = tokio::fs::read_dir(temp_dir).await?;
980 while let Some(entry) = entries.next_entry().await? {
981 let path = entry.path();
982
983 // Use comprehensive path validation
984 let relative_path = if let Ok(rel) = path.strip_prefix(temp_dir) {
985 rel
986 } else {
987 warn!("Skipping path outside temp directory: {:?}", path);
988 continue;
989 };
990
991 match validate_and_sanitize_path(relative_path, temp_dir) {
992 Ok(validated_path) => {
993 // Ensure the validated path matches the original path
994 if validated_path != path {
995 warn!("Path validation mismatch, skipping: {:?}", path);
996 continue;
997 }
998 }
999 Err(e) => {
1000 warn!("Skipping invalid path {:?}: {}", path, e);
1001 continue;
1002 }
1003 }
1004
1005 if path.is_dir() {
1006 // Check inside the directory for the binary
1007 let binary_path = path.join(binary_name);
1008
1009 // Validate the binary path as well
1010 if let Ok(metadata) = tokio::fs::metadata(&binary_path).await {
1011 let relative_binary_path = match binary_path.strip_prefix(temp_dir) {
1012 Ok(rel) => rel,
1013 Err(_) => continue,
1014 };
1015
1016 match validate_and_sanitize_path(relative_binary_path, temp_dir) {
1017 Ok(_) => {
1018 if metadata.is_file() && metadata.len() < 100 * 1024 * 1024 {
1019 return Ok(binary_path);
1020 }
1021 }
1022 Err(e) => {
1023 warn!("Invalid binary path {:?}: {}", binary_path, e);
1024 continue;
1025 }
1026 }
1027 }
1028 }
1029 // Also check if the file is directly in temp_dir
1030 if path.file_name() == Some(std::ffi::OsStr::new(binary_name))
1031 && let Ok(metadata) = tokio::fs::metadata(&path).await
1032 && metadata.is_file()
1033 && metadata.len() < 100 * 1024 * 1024
1034 {
1035 return Ok(path);
1036 }
1037 }
1038
1039 bail!("Binary not found after extraction")
1040 }
1041
1042 /// Replace the current binary with the new one
1043 async fn replace_binary(&self, new_binary: &std::path::Path) -> Result<()> {
1044 let current_exe = std::env::current_exe()?;
1045
1046 // Make sure the new binary is executable on Unix
1047 #[cfg(unix)]
1048 {
1049 use std::os::unix::fs::PermissionsExt;
1050 let mut perms = tokio::fs::metadata(&new_binary).await?.permissions();
1051 perms.set_mode(0o755);
1052 tokio::fs::set_permissions(&new_binary, perms).await?;
1053 }
1054
1055 // On Windows, we may need to retry due to file locking
1056 let mut retries = 3;
1057 while retries > 0 {
1058 match tokio::fs::rename(&new_binary, ¤t_exe).await {
1059 Ok(()) => return Ok(()),
1060 Err(e) if retries > 1 => {
1061 warn!("Failed to replace binary, retrying: {}", e);
1062 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
1063 retries -= 1;
1064 }
1065 Err(e) => bail!("Failed to replace binary: {e}"),
1066 }
1067 }
1068
1069 Ok(())
1070 }
1071
1072 /// Update to the latest available version from GitHub releases.
1073 ///
1074 /// This is a convenience method that calls [`update()`](Self::update) with `None`
1075 /// as the target version, instructing it to find and install the most recent release.
1076 ///
1077 /// # Returns
1078 ///
1079 /// - `Ok(true)` - Successfully updated to a newer version
1080 /// - `Ok(false)` - Already on the latest version (no update performed)
1081 /// - `Err(error)` - Update failed (see [`update()`](Self::update) for error conditions)
1082 ///
1083 /// # Examples
1084 ///
1085 /// ```rust,no_run
1086 /// use agpm_cli::upgrade::SelfUpdater;
1087 ///
1088 /// # async fn example() -> anyhow::Result<()> {
1089 /// let updater = SelfUpdater::new();
1090 ///
1091 /// match updater.update_to_latest().await? {
1092 /// true => println!("Successfully updated to latest version!"),
1093 /// false => println!("Already on the latest version"),
1094 /// }
1095 /// # Ok(())
1096 /// # }
1097 /// ```
1098 ///
1099 /// # See Also
1100 ///
1101 /// - [`update_to_version()`](Self::update_to_version) - Update to a specific version
1102 /// - [`check_for_update()`](Self::check_for_update) - Check for updates without installing
1103 pub async fn update_to_latest(&self) -> Result<bool> {
1104 self.update(None).await
1105 }
1106
1107 /// Update to a specific version from GitHub releases.
1108 ///
1109 /// Downloads and installs the specified version, regardless of whether it's
1110 /// newer or older than the current version. The version string will be
1111 /// automatically prefixed with 'v' if not already present.
1112 ///
1113 /// # Arguments
1114 ///
1115 /// * `version` - The target version string (e.g., "0.4.0" or "v0.4.0")
1116 ///
1117 /// # Returns
1118 ///
1119 /// - `Ok(true)` - Successfully updated to the specified version
1120 /// - `Ok(false)` - Already on the specified version (no update needed)
1121 /// - `Err(error)` - Update failed (see [`update()`](Self::update) for error conditions)
1122 ///
1123 /// # Version Format
1124 ///
1125 /// The version parameter accepts multiple formats:
1126 /// - `"0.4.0"` - Semantic version number
1127 /// - `"v0.4.0"` - Version with 'v' prefix (GitHub tag format)
1128 /// - `"0.4.0-beta.1"` - Pre-release versions
1129 ///
1130 /// # Examples
1131 ///
1132 /// ```rust,no_run
1133 /// use agpm_cli::upgrade::SelfUpdater;
1134 ///
1135 /// # async fn example() -> anyhow::Result<()> {
1136 /// let updater = SelfUpdater::new();
1137 ///
1138 /// // Update to specific stable version
1139 /// updater.update_to_version("0.4.0").await?;
1140 ///
1141 /// // Update to pre-release version
1142 /// updater.update_to_version("v0.5.0-beta.1").await?;
1143 ///
1144 /// // Force downgrade to older version
1145 /// let force_updater = updater.force(true);
1146 /// force_updater.update_to_version("0.3.0").await?;
1147 /// # Ok(())
1148 /// # }
1149 /// ```
1150 ///
1151 /// # See Also
1152 ///
1153 /// - [`update_to_latest()`](Self::update_to_latest) - Update to the newest available version
1154 /// - [`check_for_update()`](Self::check_for_update) - Check what version is available
1155 pub async fn update_to_version(&self, version: &str) -> Result<bool> {
1156 self.update(Some(version)).await
1157 }
1158}