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, ¤t_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}