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, &current_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}