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!("sha256:{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 // More precise matching to avoid false positives like "agpm" matching "agpm-dev"
147 if filename == binary_name
148 || filename.starts_with(&format!("{}-", binary_name))
149 || filename.ends_with(&format!("/{}", binary_name))
150 {
151 debug!("Found checksum for {}: {}", binary_name, checksum);
152 return Ok(Some(checksum.to_string()));
153 }
154 }
155 }
156
157 warn!("No checksum found for binary: {}", binary_name);
158 Ok(None)
159 }
160
161 /// Verify a downloaded binary using checksums from GitHub release.
162 ///
163 /// This is a convenience method that combines fetching the expected checksum
164 /// and verifying the downloaded file.
165 ///
166 /// # Arguments
167 ///
168 /// * `file_path` - Path to the downloaded binary
169 /// * `checksums_url` - URL to the checksums file in the GitHub release
170 /// * `binary_name` - Name of the binary in the checksums file
171 ///
172 /// # Returns
173 ///
174 /// - `Ok(true)` if verification succeeded
175 /// - `Ok(false)` if no checksum was available (verification skipped)
176 /// - `Err` if verification failed
177 pub async fn verify_from_release(
178 file_path: &Path,
179 checksums_url: &str,
180 binary_name: &str,
181 ) -> Result<bool> {
182 if let Some(expected) = Self::fetch_expected_checksum(checksums_url, binary_name).await? {
183 Self::verify_checksum(file_path, &expected).await?;
184 Ok(true)
185 } else {
186 warn!("No checksum available for verification, skipping");
187 Ok(false)
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::io::Write;
196 use tempfile::NamedTempFile;
197
198 #[tokio::test]
199 async fn test_compute_sha256() {
200 let mut temp_file = NamedTempFile::new().unwrap();
201 temp_file.write_all(b"Hello, World!").unwrap();
202
203 let checksum = ChecksumVerifier::compute_sha256(temp_file.path()).await.unwrap();
204
205 // Known SHA256 of "Hello, World!" with sha256: prefix
206 assert_eq!(
207 checksum,
208 "sha256:dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"
209 );
210 }
211
212 #[tokio::test]
213 async fn test_verify_checksum_success() {
214 let mut temp_file = NamedTempFile::new().unwrap();
215 temp_file.write_all(b"Test content").unwrap();
216
217 // Compute actual checksum first
218 let actual = ChecksumVerifier::compute_sha256(temp_file.path()).await.unwrap();
219
220 // Now verify with the correct checksum
221 ChecksumVerifier::verify_checksum(temp_file.path(), &actual).await.unwrap();
222 }
223
224 #[tokio::test]
225 async fn test_verify_checksum_failure() {
226 let mut temp_file = NamedTempFile::new().unwrap();
227 temp_file.write_all(b"Test content").unwrap();
228
229 let wrong_checksum = "0000000000000000000000000000000000000000000000000000000000000000";
230
231 let result = ChecksumVerifier::verify_checksum(temp_file.path(), wrong_checksum).await;
232 assert!(result.is_err());
233 assert!(result.unwrap_err().to_string().contains("Checksum verification failed"));
234 }
235
236 #[tokio::test]
237 async fn test_verify_checksum_case_insensitive() {
238 let mut temp_file = NamedTempFile::new().unwrap();
239 temp_file.write_all(b"Test").unwrap();
240
241 // SHA256 of "Test" with sha256: prefix
242 let lowercase = "sha256:532eaabd9574880dbf76b9b8cc00832c20a6ec113d682299550d7a6e0f345e25";
243 let uppercase = "sha256:532EAABD9574880DBF76B9B8CC00832C20A6EC113D682299550D7A6E0F345E25";
244
245 // Both should succeed
246 ChecksumVerifier::verify_checksum(temp_file.path(), lowercase).await.unwrap();
247 ChecksumVerifier::verify_checksum(temp_file.path(), uppercase).await.unwrap();
248 }
249}