#![allow(dead_code)]
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::Read;
use std::path::Path;
pub fn verify_checksum(file_path: &Path, expected: &str) -> Result<bool, VerifyError> {
let actual = calculate_sha256(file_path)?;
Ok(actual.to_lowercase() == expected.to_lowercase())
}
pub fn calculate_sha256(file_path: &Path) -> Result<String, VerifyError> {
let mut file = File::open(file_path).map_err(VerifyError::Io)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer).map_err(VerifyError::Io)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn parse_checksums(content: &str) -> Vec<(String, String)> {
content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let mut parts = line.splitn(2, |c: char| c.is_whitespace());
let checksum = parts.next()?.trim();
let filename = parts.next()?.trim().trim_start_matches('*');
if checksum.len() == 64 && !filename.is_empty() {
Some((checksum.to_string(), filename.to_string()))
} else {
None
}
})
.collect()
}
pub fn find_checksum(checksums_content: &str, filename: &str) -> Option<String> {
let checksums = parse_checksums(checksums_content);
checksums
.into_iter()
.find(|(_, name)| name == filename || name.ends_with(filename))
.map(|(sum, _)| sum)
}
pub(crate) const COSIGN_CERT_IDENTITY_REGEX: &str = concat!(
r"^https://github\.com/bearbinary/jarvy/\.github/workflows/[^@]+@",
r"refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z\.\-]+)?$",
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureOutcome {
Verified,
CosignMissing,
SignatureFilesMissing,
Rejected(String),
}
pub fn verify_sigstore_signature(file_path: &Path) -> Result<SignatureOutcome, VerifyError> {
use std::process::Command;
if Command::new("cosign").arg("version").output().is_err() {
tracing::warn!(
event = "update.signature.skipped",
reason = "cosign_missing",
file = %file_path.display(),
);
return Ok(SignatureOutcome::CosignMissing);
}
let sig_path = file_path.with_extension(format!(
"{}.sig",
file_path.extension().unwrap_or_default().to_string_lossy()
));
let cert_path = file_path.with_extension(format!(
"{}.pem",
file_path.extension().unwrap_or_default().to_string_lossy()
));
if !sig_path.exists() || !cert_path.exists() {
tracing::warn!(
event = "update.signature.skipped",
reason = "sig_files_missing",
file = %file_path.display(),
);
return Ok(SignatureOutcome::SignatureFilesMissing);
}
let output = Command::new("cosign")
.args([
"verify-blob",
"--signature",
&sig_path.to_string_lossy(),
"--certificate",
&cert_path.to_string_lossy(),
"--certificate-identity-regexp",
COSIGN_CERT_IDENTITY_REGEX,
"--certificate-oidc-issuer",
"https://token.actions.githubusercontent.com",
])
.arg(file_path)
.output()
.map_err(VerifyError::Io)?;
if output.status.success() {
tracing::info!(
event = "update.signature.verified",
file = %file_path.display(),
);
Ok(SignatureOutcome::Verified)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
tracing::error!(
event = "update.signature.failed",
file = %file_path.display(),
error = %stderr,
);
Ok(SignatureOutcome::Rejected(stderr))
}
}
pub fn signature_outcome_is_acceptable(
outcome: &SignatureOutcome,
allow_unsigned: bool,
) -> Result<(), String> {
match outcome {
SignatureOutcome::Verified => Ok(()),
SignatureOutcome::CosignMissing => {
if allow_unsigned {
Ok(())
} else {
Err(
"cosign is not installed; install it (https://docs.sigstore.dev/cosign/) \
or re-run with --allow-unsigned to accept supply-chain risk"
.to_string(),
)
}
}
SignatureOutcome::SignatureFilesMissing => {
if allow_unsigned {
Ok(())
} else {
Err(
"release does not include .sig/.pem files; refusing to install \
unsigned binary. Re-run with --allow-unsigned to override."
.to_string(),
)
}
}
SignatureOutcome::Rejected(stderr) => Err(format!(
"Sigstore verification rejected the artifact: {stderr}"
)),
}
}
pub fn unsigned_override_from_env() -> bool {
match std::env::var("JARVY_ALLOW_UNSIGNED_UPDATE") {
Ok(v) => {
let t = v.trim().to_ascii_lowercase();
!t.is_empty() && t != "0" && t != "false" && t != "no"
}
Err(_) => false,
}
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Checksum mismatch")]
ChecksumMismatch,
#[error("Checksum not found for file: {0}")]
ChecksumNotFound(String),
#[error("Signature verification failed: {0}")]
SignatureInvalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_calculate_sha256() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"hello world").unwrap();
drop(file);
let hash = calculate_sha256(&file_path).unwrap();
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn test_verify_checksum() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"hello world").unwrap();
drop(file);
let valid = verify_checksum(
&file_path,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
)
.unwrap();
assert!(valid);
let invalid = verify_checksum(
&file_path,
"0000000000000000000000000000000000000000000000000000000000000000",
)
.unwrap();
assert!(!invalid);
}
#[test]
fn test_parse_checksums() {
let content = r#"
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa jarvy-darwin-aarch64.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb jarvy-linux-x86_64.tar.gz
# Comment line
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc *jarvy-windows-x86_64.zip
"#;
let checksums = parse_checksums(content);
assert_eq!(checksums.len(), 3);
assert_eq!(
checksums[0].0,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(checksums[0].1, "jarvy-darwin-aarch64.tar.gz");
assert_eq!(checksums[2].1, "jarvy-windows-x86_64.zip");
}
#[test]
fn cert_identity_regex_is_anchored() {
let re = regex::Regex::new(COSIGN_CERT_IDENTITY_REGEX).expect("valid regex");
assert!(re.is_match(
"https://github.com/bearbinary/jarvy/.github/workflows/release.yml@refs/tags/v1.2.3"
));
assert!(re.is_match(
"https://github.com/bearbinary/jarvy/.github/workflows/release.yml@refs/tags/v1.2.3-rc.1"
));
assert!(!re.is_match(
"https://github.com/attacker/repo/.github/workflows/foo.yml@refs/heads/main\
github.com/bearbinary/jarvy"
));
assert!(!re.is_match(
"https://github.com/bearbinary/jarvy/.github/workflows/release.yml@refs/heads/main"
));
assert!(!re.is_match(
"https://gitlab.com/bearbinary/jarvy/.github/workflows/release.yml@refs/tags/v1.0.0"
));
}
#[test]
fn cosign_missing_is_not_acceptable_by_default() {
let outcome = SignatureOutcome::CosignMissing;
assert!(signature_outcome_is_acceptable(&outcome, false).is_err());
}
#[test]
fn cosign_missing_acceptable_with_override() {
let outcome = SignatureOutcome::CosignMissing;
assert!(signature_outcome_is_acceptable(&outcome, true).is_ok());
}
#[test]
fn sig_files_missing_is_not_acceptable_by_default() {
let outcome = SignatureOutcome::SignatureFilesMissing;
assert!(signature_outcome_is_acceptable(&outcome, false).is_err());
}
#[test]
fn rejected_outcome_never_acceptable() {
let outcome = SignatureOutcome::Rejected("bad cert".into());
assert!(signature_outcome_is_acceptable(&outcome, true).is_err());
assert!(signature_outcome_is_acceptable(&outcome, false).is_err());
}
#[test]
fn verified_outcome_always_acceptable() {
let outcome = SignatureOutcome::Verified;
assert!(signature_outcome_is_acceptable(&outcome, false).is_ok());
assert!(signature_outcome_is_acceptable(&outcome, true).is_ok());
}
#[test]
fn unsigned_override_env_parsing() {
let key = "JARVY_ALLOW_UNSIGNED_UPDATE";
let prev = std::env::var(key).ok();
#[allow(unsafe_code)]
unsafe {
std::env::remove_var(key);
}
assert!(!unsigned_override_from_env());
for truthy in ["1", "true", "yes", "TRUE", "Y"] {
#[allow(unsafe_code)]
unsafe {
std::env::set_var(key, truthy);
}
assert!(
unsigned_override_from_env(),
"expected truthy for value {truthy:?}"
);
}
for falsy in ["0", "false", "no", ""] {
#[allow(unsafe_code)]
unsafe {
std::env::set_var(key, falsy);
}
assert!(
!unsigned_override_from_env(),
"expected falsy for value {falsy:?}"
);
}
match prev {
Some(v) => {
#[allow(unsafe_code)]
unsafe {
std::env::set_var(key, v);
}
}
None => {
#[allow(unsafe_code)]
unsafe {
std::env::remove_var(key);
}
}
}
}
#[test]
fn test_find_checksum() {
let content = r#"
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa jarvy-darwin-aarch64.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb jarvy-linux-x86_64.tar.gz
"#;
let found = find_checksum(content, "jarvy-darwin-aarch64.tar.gz");
assert_eq!(
found,
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string())
);
let not_found = find_checksum(content, "nonexistent.tar.gz");
assert_eq!(not_found, None);
}
}