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