Skip to content

Commit 0116cd3

Browse files
authored
feat: add checksum verification (#14)
- Add checksum module with SHA256, SHA512, and MD5 support - Auto-detect checksum files in releases (.sha256, .sha512, .md5) - Add --verify flag to install command - Verify downloads against published checksums - Support sha256sum-style checksum files Part of v0.3.0 phase 2 (checksum verification).
1 parent d5fc996 commit 0116cd3

9 files changed

Lines changed: 378 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 79 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ indexmap = "=2.0.2"
3232
url = "=2.4.1"
3333
base64 = "=0.21.7"
3434
base64ct = "=1.6.0"
35-
atty = "=0.2.14"
35+
sha2 = "=0.10.8"
36+
md5 = "=0.7.0"
3637

37-
[dev-dependencies]
38+
atty = "=0.2.14"
3839
assert_cmd = "2.0.12"

src/checksum.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
use anyhow::{bail, Context, Result};
2+
use sha2::{Digest, Sha256, Sha512};
3+
use std::fs;
4+
use std::io::Read;
5+
use std::path::Path;
6+
7+
/// Supported checksum algorithms
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub enum ChecksumAlgorithm {
10+
Sha256,
11+
Sha512,
12+
Md5,
13+
}
14+
15+
/// Checksum file information
16+
#[derive(Debug, Clone)]
17+
#[allow(dead_code)]
18+
pub struct ChecksumFile {
19+
pub algorithm: ChecksumAlgorithm,
20+
pub filename: String,
21+
pub expected_hash: String,
22+
}
23+
24+
/// Detect if a filename is a checksum file
25+
pub fn is_checksum_file(filename: &str) -> Option<ChecksumAlgorithm> {
26+
let lower = filename.to_lowercase();
27+
if lower.ends_with(".sha256") || lower.contains(".sha256.") {
28+
Some(ChecksumAlgorithm::Sha256)
29+
} else if lower.ends_with(".sha512") || lower.contains(".sha512.") {
30+
Some(ChecksumAlgorithm::Sha512)
31+
} else if lower.ends_with(".md5") || lower.contains(".md5.") {
32+
Some(ChecksumAlgorithm::Md5)
33+
} else {
34+
None
35+
}
36+
}
37+
38+
/// Find checksum file for a given asset in release assets
39+
pub fn find_checksum_file(
40+
asset_name: &str,
41+
assets: &[crate::github::Asset],
42+
) -> Option<(ChecksumAlgorithm, String)> {
43+
// Try exact match patterns first
44+
let patterns = vec![
45+
format!("{}.sha256", asset_name),
46+
format!("{}.sha512", asset_name),
47+
format!("{}.md5", asset_name),
48+
];
49+
50+
for asset in assets {
51+
for pattern in &patterns {
52+
if asset.name == *pattern {
53+
let algo = is_checksum_file(&asset.name)?;
54+
return Some((algo, asset.browser_download_url.clone()));
55+
}
56+
}
57+
}
58+
59+
// Try generic checksum files (checksums.txt, SHA256SUMS, etc.)
60+
for asset in assets {
61+
let name_lower = asset.name.to_lowercase();
62+
if name_lower.contains("checksum") || name_lower.contains("sha256sum") {
63+
return Some((
64+
ChecksumAlgorithm::Sha256,
65+
asset.browser_download_url.clone(),
66+
));
67+
}
68+
}
69+
70+
None
71+
}
72+
73+
/// Verify file against expected checksum
74+
pub fn verify_file(file_path: &Path, expected: &str, algo: ChecksumAlgorithm) -> Result<()> {
75+
let calculated = calculate_checksum(file_path, algo)?;
76+
let expected_clean = expected.trim().to_lowercase();
77+
let calculated_clean = calculated.to_lowercase();
78+
79+
if expected_clean != calculated_clean {
80+
bail!(
81+
"Checksum mismatch:\n Expected: {}\n Calculated: {}",
82+
expected_clean,
83+
calculated_clean
84+
);
85+
}
86+
87+
Ok(())
88+
}
89+
90+
/// Calculate checksum of a file
91+
pub fn calculate_checksum(file_path: &Path, algo: ChecksumAlgorithm) -> Result<String> {
92+
let mut file = fs::File::open(file_path).context("Failed to open file for checksum")?;
93+
let mut buffer = Vec::new();
94+
file.read_to_end(&mut buffer)
95+
.context("Failed to read file")?;
96+
97+
let hash = match algo {
98+
ChecksumAlgorithm::Sha256 => {
99+
let mut hasher = Sha256::new();
100+
hasher.update(&buffer);
101+
format!("{:x}", hasher.finalize())
102+
}
103+
ChecksumAlgorithm::Sha512 => {
104+
let mut hasher = Sha512::new();
105+
hasher.update(&buffer);
106+
format!("{:x}", hasher.finalize())
107+
}
108+
ChecksumAlgorithm::Md5 => {
109+
let hash = md5::compute(&buffer);
110+
format!("{:x}", hash)
111+
}
112+
};
113+
114+
Ok(hash)
115+
}
116+
117+
/// Parse checksum from checksum file content
118+
pub fn parse_checksum_file(content: &str, target_filename: &str) -> Option<String> {
119+
for line in content.lines() {
120+
let line = line.trim();
121+
if line.is_empty() || line.starts_with('#') {
122+
continue;
123+
}
124+
125+
// Handle "HASH filename" format (sha256sum style)
126+
let parts: Vec<&str> = line.split_whitespace().collect();
127+
if parts.len() >= 2 {
128+
let hash = parts[0];
129+
let filename = parts[1].trim_start_matches('*'); // Remove binary marker
130+
if filename == target_filename {
131+
return Some(hash.to_string());
132+
}
133+
}
134+
}
135+
None
136+
}

src/cli.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub enum Commands {
1919
force: bool,
2020
#[arg(long)]
2121
dry_run: bool,
22+
#[arg(long)]
23+
verify: bool,
2224
},
2325
List {
2426
#[arg(short, long)]

src/github.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,18 @@ impl GithubClient {
345345

346346
Ok(())
347347
}
348+
349+
/// Download text content from URL
350+
pub async fn download_text(&self, url: &str) -> std::result::Result<String, GithubError> {
351+
let resp = self.add_auth(self.client.get(url)).send().await?;
352+
if !resp.status().is_success() {
353+
return Err(GithubError::DownloadError(format!(
354+
"HTTP {}",
355+
resp.status()
356+
)));
357+
}
358+
Ok(resp.text().await?)
359+
}
348360
}
349361

350362
/// Find the best matching asset for a given platform

src/install.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::fs;
33
use std::path::{Path, PathBuf};
44
use tracing::warn;
55

6+
use crate::checksum::{find_checksum_file, verify_file};
67
use crate::config::Config;
78
use crate::extract::extract_archive;
89
use crate::github::{find_matching_asset, parse_package, Asset, GithubClient, Platform, Release};
@@ -13,6 +14,7 @@ pub async fn handle_install(
1314
package: &str,
1415
force: bool,
1516
dry_run: bool,
17+
verify: bool,
1618
config: &Config,
1719
) -> Result<()> {
1820
let (owner, repo, version) = parse_package(package)?;
@@ -63,6 +65,26 @@ pub async fn handle_install(
6365
.download_asset(asset, &download_path, config.download.show_progress)
6466
.await?;
6567

68+
// Verify checksum if requested or configured
69+
if verify || config.download.verify_checksums {
70+
if let Some((algo, checksum_url)) = find_checksum_file(&asset.name, &release.assets) {
71+
let checksum_data = client.download_text(&checksum_url).await?;
72+
if let Some(expected) =
73+
crate::checksum::parse_checksum_file(&checksum_data, &asset.name)
74+
{
75+
if !config.output.quiet {
76+
println!("Verifying checksum...");
77+
}
78+
verify_file(&download_path, &expected, algo)?;
79+
if !config.output.quiet {
80+
println!("Checksum verified");
81+
}
82+
}
83+
} else if verify {
84+
bail!("Checksum verification requested but no checksum file found");
85+
}
86+
}
87+
6688
let pkg_install_dir = config.install_dir.join("packages").join(&key);
6789
fs::create_dir_all(&pkg_install_dir)?;
6890

@@ -132,7 +154,7 @@ async fn update_one(package: &str, config: &Config) -> Result<()> {
132154
);
133155
}
134156
crate::registry::uninstall(package, &config.install_dir)?;
135-
handle_install(package, false, false, config).await
157+
handle_install(package, false, false, false, config).await
136158
}
137159

138160
async fn update_all(config: &Config) -> Result<()> {

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod checksum;
12
pub mod cli;
23
pub mod config;
34
pub mod extract;

0 commit comments

Comments
 (0)