agpm_cli/upgrade/
verification.rs

1use anyhow::{Context, Result, bail};
2use sha2::{Digest, Sha256};
3use std::path::Path;
4use tokio::fs;
5use tracing::{debug, info, warn};
6
7/// Verifies the integrity of a downloaded binary using SHA256 checksum.
8///
9/// This module provides checksum verification functionality for downloaded binaries
10/// to ensure they haven't been corrupted or tampered with during download.
11///
12/// # Security Benefits
13///
14/// - **Download Integrity**: Detects corrupted or incomplete downloads
15/// - **Tamper Detection**: Identifies potentially modified binaries
16/// - **Supply Chain Security**: Helps ensure binary authenticity
17/// - **Network Reliability**: Catches network-induced corruption
18pub struct ChecksumVerifier;
19
20impl ChecksumVerifier {
21    /// Compute the SHA256 checksum of a file.
22    ///
23    /// # Arguments
24    ///
25    /// * `file_path` - Path to the file to compute checksum for
26    ///
27    /// # Returns
28    ///
29    /// The hex-encoded SHA256 checksum string
30    ///
31    /// # Examples
32    ///
33    /// ```rust,no_run
34    /// use agpm_cli::upgrade::verification::ChecksumVerifier;
35    /// use std::path::Path;
36    ///
37    /// # async fn example() -> anyhow::Result<()> {
38    /// let checksum = ChecksumVerifier::compute_sha256(Path::new("/path/to/binary")).await?;
39    /// println!("SHA256: {}", checksum);
40    /// # Ok(())
41    /// # }
42    /// ```
43    pub async fn compute_sha256(file_path: &Path) -> Result<String> {
44        debug!("Computing SHA256 checksum for: {:?}", file_path);
45
46        let contents = fs::read(file_path)
47            .await
48            .with_context(|| format!("Failed to read file: {file_path:?}"))?;
49
50        let mut hasher = Sha256::new();
51        hasher.update(&contents);
52        let result = hasher.finalize();
53
54        Ok(format!("{result:x}"))
55    }
56
57    /// Verify a file against an expected checksum.
58    ///
59    /// # Arguments
60    ///
61    /// * `file_path` - Path to the file to verify
62    /// * `expected_checksum` - The expected SHA256 checksum (hex-encoded)
63    ///
64    /// # Returns
65    ///
66    /// - `Ok(())` if checksums match
67    /// - `Err` if checksums don't match or verification fails
68    ///
69    /// # Examples
70    ///
71    /// ```rust,no_run
72    /// use agpm_cli::upgrade::verification::ChecksumVerifier;
73    /// use std::path::Path;
74    ///
75    /// # async fn example() -> anyhow::Result<()> {
76    /// let file_path = Path::new("/path/to/binary");
77    /// let expected = "abc123...";
78    ///
79    /// ChecksumVerifier::verify_checksum(file_path, expected).await?;
80    /// println!("Checksum verified successfully!");
81    /// # Ok(())
82    /// # }
83    /// ```
84    pub async fn verify_checksum(file_path: &Path, expected_checksum: &str) -> Result<()> {
85        info!("Verifying checksum for: {:?}", file_path);
86
87        let actual_checksum = Self::compute_sha256(file_path).await?;
88
89        // Case-insensitive comparison (checksums may be uppercase or lowercase)
90        if actual_checksum.to_lowercase() != expected_checksum.to_lowercase() {
91            bail!(
92                "Checksum verification failed!\n  Expected: {expected_checksum}\n  Actual:   {actual_checksum}"
93            );
94        }
95
96        info!("Checksum verification successful");
97        Ok(())
98    }
99
100    /// Download and parse a checksums file from a GitHub release.
101    ///
102    /// GitHub releases often include a checksums.txt or SHA256SUMS file containing
103    /// checksums for all release artifacts. This function downloads and parses such files.
104    ///
105    /// # Arguments
106    ///
107    /// * `checksums_url` - URL to the checksums file
108    /// * `binary_name` - Name of the binary to find checksum for
109    ///
110    /// # Returns
111    ///
112    /// The expected checksum for the specified binary, or None if not found
113    ///
114    /// # Checksum File Format
115    ///
116    /// Expected format (one per line):
117    /// ```text
118    /// abc123def456...  agpm-linux-x86_64
119    /// 789ghi012jkl...  agpm-macos-aarch64
120    /// ```
121    pub async fn fetch_expected_checksum(
122        checksums_url: &str,
123        binary_name: &str,
124    ) -> Result<Option<String>> {
125        debug!("Fetching checksums from: {}", checksums_url);
126
127        // Use reqwest to download the checksums file
128        let client = reqwest::Client::new();
129        let response =
130            client.get(checksums_url).send().await.context("Failed to fetch checksums file")?;
131
132        if !response.status().is_success() {
133            warn!("Failed to fetch checksums file: HTTP {}", response.status());
134            return Ok(None);
135        }
136
137        let content = response.text().await.context("Failed to read checksums file content")?;
138
139        // Parse the checksums file
140        for line in content.lines() {
141            let parts: Vec<&str> = line.split_whitespace().collect();
142            if parts.len() == 2 {
143                let (checksum, filename) = (parts[0], parts[1]);
144
145                // Check if this line is for our binary
146                // Handle both exact matches and pattern matches (e.g., agpm-linux-x86_64)
147                if filename == binary_name || filename.contains(binary_name) {
148                    debug!("Found checksum for {}: {}", binary_name, checksum);
149                    return Ok(Some(checksum.to_string()));
150                }
151            }
152        }
153
154        warn!("No checksum found for binary: {}", binary_name);
155        Ok(None)
156    }
157
158    /// Verify a downloaded binary using checksums from GitHub release.
159    ///
160    /// This is a convenience method that combines fetching the expected checksum
161    /// and verifying the downloaded file.
162    ///
163    /// # Arguments
164    ///
165    /// * `file_path` - Path to the downloaded binary
166    /// * `checksums_url` - URL to the checksums file in the GitHub release
167    /// * `binary_name` - Name of the binary in the checksums file
168    ///
169    /// # Returns
170    ///
171    /// - `Ok(true)` if verification succeeded
172    /// - `Ok(false)` if no checksum was available (verification skipped)
173    /// - `Err` if verification failed
174    pub async fn verify_from_release(
175        file_path: &Path,
176        checksums_url: &str,
177        binary_name: &str,
178    ) -> Result<bool> {
179        if let Some(expected) = Self::fetch_expected_checksum(checksums_url, binary_name).await? {
180            Self::verify_checksum(file_path, &expected).await?;
181            Ok(true)
182        } else {
183            warn!("No checksum available for verification, skipping");
184            Ok(false)
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::io::Write;
193    use tempfile::NamedTempFile;
194
195    #[tokio::test]
196    async fn test_compute_sha256() {
197        let mut temp_file = NamedTempFile::new().unwrap();
198        temp_file.write_all(b"Hello, World!").unwrap();
199
200        let checksum = ChecksumVerifier::compute_sha256(temp_file.path()).await.unwrap();
201
202        // Known SHA256 of "Hello, World!"
203        assert_eq!(checksum, "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f");
204    }
205
206    #[tokio::test]
207    async fn test_verify_checksum_success() {
208        let mut temp_file = NamedTempFile::new().unwrap();
209        temp_file.write_all(b"Test content").unwrap();
210
211        // Compute actual checksum first
212        let actual = ChecksumVerifier::compute_sha256(temp_file.path()).await.unwrap();
213
214        // Now verify with the correct checksum
215        ChecksumVerifier::verify_checksum(temp_file.path(), &actual).await.unwrap();
216    }
217
218    #[tokio::test]
219    async fn test_verify_checksum_failure() {
220        let mut temp_file = NamedTempFile::new().unwrap();
221        temp_file.write_all(b"Test content").unwrap();
222
223        let wrong_checksum = "0000000000000000000000000000000000000000000000000000000000000000";
224
225        let result = ChecksumVerifier::verify_checksum(temp_file.path(), wrong_checksum).await;
226        assert!(result.is_err());
227        assert!(result.unwrap_err().to_string().contains("Checksum verification failed"));
228    }
229
230    #[tokio::test]
231    async fn test_verify_checksum_case_insensitive() {
232        let mut temp_file = NamedTempFile::new().unwrap();
233        temp_file.write_all(b"Test").unwrap();
234
235        // SHA256 of "Test"
236        let lowercase = "532eaabd9574880dbf76b9b8cc00832c20a6ec113d682299550d7a6e0f345e25";
237        let uppercase = "532EAABD9574880DBF76B9B8CC00832C20A6EC113D682299550D7A6E0F345E25";
238
239        // Both should succeed
240        ChecksumVerifier::verify_checksum(temp_file.path(), lowercase).await.unwrap();
241        ChecksumVerifier::verify_checksum(temp_file.path(), uppercase).await.unwrap();
242    }
243}