use super::types::UpdateError;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use which::which;
#[derive(Debug)]
#[allow(dead_code)]
pub struct VerificationResult {
pub checksum_valid: bool,
pub signature_valid: Option<bool>,
}
pub async fn verify_checksum(
file_path: &std::path::Path,
expected: &str,
) -> Result<VerificationResult, UpdateError> {
verify_checksum_and_signature(file_path, expected, None).await
}
pub async fn verify_checksum_and_signature(
file_path: &std::path::Path,
expected: &str,
signature_bundle: Option<&std::path::Path>,
) -> Result<VerificationResult, UpdateError> {
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| UpdateError::InstallFailed(format!("Failed to open file: {}", e)))?;
let mut hasher = blake3::Hasher::new();
let mut buffer = vec![0u8; 64 * 1024];
loop {
let bytes_read = file
.read(&mut buffer)
.await
.map_err(|e| UpdateError::InstallFailed(format!("Failed to read file: {}", e)))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let actual = hasher.finalize().to_hex().to_string();
let sha256_actual = compute_sha256(file_path).await?;
let checksum_valid =
actual.eq_ignore_ascii_case(expected) || sha256_actual.eq_ignore_ascii_case(expected);
if !checksum_valid {
return Err(UpdateError::ChecksumMismatch {
expected: expected.to_string(),
actual: sha256_actual,
});
}
let signature_valid = if let Some(bundle_path) = signature_bundle {
Some(verify_signature(file_path, bundle_path).await?)
} else {
None
};
Ok(VerificationResult {
checksum_valid: true,
signature_valid,
})
}
async fn compute_sha256(file_path: &std::path::Path) -> Result<String, UpdateError> {
use std::io::Read;
let path = file_path.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut file = std::fs::File::open(&path)
.map_err(|e| UpdateError::InstallFailed(format!("Failed to open file: {}", e)))?;
let mut hasher = sha2::Sha256::new();
let mut buffer = vec![0u8; 64 * 1024];
loop {
let bytes_read = file
.read(&mut buffer)
.map_err(|e| UpdateError::InstallFailed(format!("Failed to read file: {}", e)))?;
if bytes_read == 0 {
break;
}
use sha2::Digest;
hasher.update(&buffer[..bytes_read]);
}
use sha2::Digest;
Ok(bytes_to_lower_hex(hasher.finalize()))
})
.await
.map_err(|e| UpdateError::InstallFailed(format!("Task failed: {}", e)))?
}
fn bytes_to_lower_hex(bytes: impl AsRef<[u8]>) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let bytes = bytes.as_ref();
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
const GITHUB_ACTIONS_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
const RCH_RELEASE_IDENTITY_PATTERN: &str = r"^https://github\.com/Dicklesworthstone/remote_compilation_helper/\.github/workflows/release\.yml@refs/.*$";
pub(crate) async fn verify_signature(
file_path: &std::path::Path,
bundle_path: &std::path::Path,
) -> Result<bool, UpdateError> {
let executable = which("cosign").map_err(|_| {
UpdateError::SignatureVerificationFailed("cosign not found in PATH".to_string())
})?;
let command = cosign_command(&executable, file_path, bundle_path);
let output =
capture_verifier_output(command, std::time::Duration::from_secs(120), 1024 * 1024).await?;
if output.status.success() {
Ok(true)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(UpdateError::SignatureVerificationFailed(format!(
"cosign verify-blob failed: {}{}",
stderr.trim(),
if stdout.trim().is_empty() {
String::new()
} else {
format!(" | {}", stdout.trim())
}
)))
}
}
fn cosign_command(
executable: &std::path::Path,
file_path: &std::path::Path,
bundle_path: &std::path::Path,
) -> Command {
let mut command = Command::new(executable);
command
.arg("verify-blob")
.arg("--bundle")
.arg(bundle_path)
.arg("--certificate-identity-regexp")
.arg(RCH_RELEASE_IDENTITY_PATTERN)
.arg("--certificate-oidc-issuer")
.arg(GITHUB_ACTIONS_OIDC_ISSUER)
.arg(file_path);
command
}
async fn read_verifier_output(
mut reader: impl tokio::io::AsyncRead + Unpin,
limit: usize,
) -> std::io::Result<Vec<u8>> {
let mut output = Vec::new();
let mut buffer = [0u8; 8192];
loop {
let length = match reader.read(&mut buffer).await {
Ok(0) => return Ok(output),
Ok(length) => length,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
};
if length > limit.saturating_sub(output.len()) {
return Err(std::io::Error::other(
"signature verifier output limit exceeded",
));
}
output.extend_from_slice(&buffer[..length]);
}
}
async fn capture_verifier_output(
mut command: Command,
budget: std::time::Duration,
output_limit: usize,
) -> Result<std::process::Output, UpdateError> {
use std::process::Stdio;
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| {
UpdateError::SignatureVerificationFailed(format!("Failed to execute cosign: {}", e))
})?;
let stdout = child.stdout.take().ok_or_else(|| {
UpdateError::SignatureVerificationFailed("missing verifier stdout pipe".to_owned())
})?;
let stderr = child.stderr.take().ok_or_else(|| {
UpdateError::SignatureVerificationFailed("missing verifier stderr pipe".to_owned())
})?;
let result = tokio::time::timeout(budget, async {
let (stdout, stderr, status) = tokio::try_join!(
read_verifier_output(stdout, output_limit),
read_verifier_output(stderr, output_limit),
child.wait(),
)?;
Ok::<_, std::io::Error>(std::process::Output {
status,
stdout,
stderr,
})
})
.await;
let mut failure = match result {
Ok(Ok(output)) => return Ok(output),
Ok(Err(error)) => format!("cosign output collection failed: {error}"),
Err(_) => format!("cosign verification timed out after {budget:?}"),
};
match tokio::time::timeout(std::time::Duration::from_secs(2), child.kill()).await {
Ok(Ok(())) => {}
Ok(Err(error)) => failure.push_str(&format!("; verifier teardown failed: {error}")),
Err(_) => failure.push_str("; verifier teardown timed out"),
}
Err(UpdateError::SignatureVerificationFailed(failure))
}
pub(crate) const RELEASE_MINISIGN_PUBLIC_KEY: &str =
"RWSoYi6NXJWzaRs1mJmOwwXrZfPWcq6MXnQlNMLBYKzlIQTLwuVQG6uO";
pub(crate) fn verify_minisign(
archive: &std::path::Path,
signature_text: &str,
) -> Result<(), UpdateError> {
verify_minisign_with_key(archive, signature_text, RELEASE_MINISIGN_PUBLIC_KEY)
}
fn verify_minisign_with_key(
archive: &std::path::Path,
signature_text: &str,
public_key: &str,
) -> Result<(), UpdateError> {
let fail = |detail: String| UpdateError::SignatureVerificationFailed(detail);
let key = minisign_verify::PublicKey::from_base64(public_key)
.map_err(|e| fail(format!("invalid pinned minisign key: {e}")))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| fail(format!("malformed .minisig: {e}")))?;
let mut file = std::fs::File::open(archive)
.map_err(|e| fail(format!("cannot read {}: {e}", archive.display())))?;
verify_minisign_reader(&mut file, &key, &signature)
}
const VERIFICATION_CHUNK_BYTES: usize = 64 * 1024;
fn verify_minisign_reader(
reader: &mut impl std::io::Read,
key: &minisign_verify::PublicKey,
signature: &minisign_verify::Signature,
) -> Result<(), UpdateError> {
let fail = |detail: String| UpdateError::SignatureVerificationFailed(detail);
let mut verifier = key
.verify_stream(signature)
.map_err(|e| fail(format!("minisign verification failed: {e}")))?;
let mut buffer = vec![0u8; VERIFICATION_CHUNK_BYTES];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(length) => verifier.update(&buffer[..length]),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(fail(format!("cannot read signed archive: {error}"))),
}
}
verifier
.finalize()
.map_err(|e| fail(format!("minisign verification failed: {e}")))
}
#[allow(dead_code)]
pub fn verify_sha256_bytes(content: &[u8], expected: &str) -> Result<(), UpdateError> {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(content);
let actual = bytes_to_lower_hex(hasher.finalize());
if actual.eq_ignore_ascii_case(expected) {
Ok(())
} else {
Err(UpdateError::ChecksumMismatch {
expected: expected.to_string(),
actual,
})
}
}
#[cfg(test)]
mod minisign_tests {
use super::*;
const TEST_KEY: &str = "RWTZPaZ0gaEhqMa/pPZEdaALWkxxAm/EtQzB6egPldD4Uh1cCRlw/b0w";
const PAYLOAD: &[u8] = b"rch minisign test payload v1\n";
const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\n\
RUTZPaZ0gaEhqDSXunnChY49+8G9WeMdP7wwnCG9s382LwYf7h09hfwypAG0pKwYrhcX+N1Ujyzv1r4ZpRClO4To6jTL1DR1ewo=\n\
trusted comment: rch test fixture\n\
xfmvGjlaWGXfdNKtFwBNm45V0V6VPZ3lLFhIzfLmQ7xUjPzacD1iHPC0MO2oczUBCt2gAZtoGnuQMxpswMpMCw==\n";
fn archive(bytes: &[u8]) -> tempfile::NamedTempFile {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(file.path(), bytes).unwrap();
file
}
#[test]
fn genuine_signature_verifies() {
let file = archive(PAYLOAD);
verify_minisign_with_key(file.path(), SIGNATURE, TEST_KEY).unwrap();
}
#[test]
fn tampered_archive_is_refused_even_though_it_hashes_cleanly() {
let file = archive(b"rch minisign test payload v2\n");
let error = verify_minisign_with_key(file.path(), SIGNATURE, TEST_KEY).unwrap_err();
assert!(
error.to_string().contains("minisign verification failed"),
"{error}"
);
}
#[test]
fn signature_from_another_key_is_refused() {
let file = archive(PAYLOAD);
assert!(verify_minisign(file.path(), SIGNATURE).is_err());
}
#[test]
fn malformed_signature_is_refused() {
let file = archive(PAYLOAD);
let error = verify_minisign_with_key(file.path(), "not a minisig", TEST_KEY).unwrap_err();
assert!(error.to_string().contains("malformed .minisig"), "{error}");
}
#[test]
fn pinned_release_key_parses() {
minisign_verify::PublicKey::from_base64(RELEASE_MINISIGN_PUBLIC_KEY).unwrap();
}
struct Chunked<'a> {
remaining: &'a [u8],
chunk: usize,
interrupt: bool,
fail_at_eof: bool,
reads: usize,
}
impl std::io::Read for Chunked<'_> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
assert!(buffer.len() <= VERIFICATION_CHUNK_BYTES);
self.reads += 1;
if self.interrupt {
self.interrupt = false;
return Err(std::io::ErrorKind::Interrupted.into());
}
if self.remaining.is_empty() && self.fail_at_eof {
return Err(std::io::Error::other("injected read failure"));
}
let length = self.remaining.len().min(self.chunk).min(buffer.len());
buffer[..length].copy_from_slice(&self.remaining[..length]);
self.remaining = &self.remaining[length..];
Ok(length)
}
}
#[test]
fn minisign_stream_accepts_short_reads_and_interrupted_io() {
let key = minisign_verify::PublicKey::from_base64(TEST_KEY).unwrap();
let signature = minisign_verify::Signature::decode(SIGNATURE).unwrap();
for chunk in 1..=PAYLOAD.len() + 1 {
let mut reader = Chunked {
remaining: PAYLOAD,
chunk,
interrupt: true,
fail_at_eof: false,
reads: 0,
};
verify_minisign_reader(&mut reader, &key, &signature).unwrap();
assert!(reader.remaining.is_empty());
assert!(
reader.reads >= 3,
"interruption, payload and EOF are all required"
);
}
}
#[test]
fn minisign_stream_never_turns_read_failure_into_verified_eof() {
let key = minisign_verify::PublicKey::from_base64(TEST_KEY).unwrap();
let signature = minisign_verify::Signature::decode(SIGNATURE).unwrap();
for data in [&PAYLOAD[..0], &PAYLOAD[..5], PAYLOAD] {
let mut reader = Chunked {
remaining: data,
chunk: 3,
interrupt: false,
fail_at_eof: true,
reads: 0,
};
let error = verify_minisign_reader(&mut reader, &key, &signature).unwrap_err();
assert!(
error.to_string().contains("injected read failure"),
"{error}"
);
}
}
#[test]
fn minisign_stream_checks_truncation_appended_bytes_and_trusted_comment() {
let key = minisign_verify::PublicKey::from_base64(TEST_KEY).unwrap();
let signature = minisign_verify::Signature::decode(SIGNATURE).unwrap();
assert!(
verify_minisign_reader(&mut &PAYLOAD[..PAYLOAD.len() - 1], &key, &signature).is_err()
);
let mut appended = PAYLOAD.to_vec();
appended.push(0);
assert!(verify_minisign_reader(&mut appended.as_slice(), &key, &signature).is_err());
let changed = SIGNATURE.replace(
"trusted comment: rch test fixture",
"trusted comment: forged",
);
let changed = minisign_verify::Signature::decode(&changed).unwrap();
assert!(verify_minisign_reader(&mut &PAYLOAD[..], &key, &changed).is_err());
}
#[test]
fn minisign_stream_rejects_a_foreign_key_before_reading_payload() {
let key = minisign_verify::PublicKey::from_base64(RELEASE_MINISIGN_PUBLIC_KEY).unwrap();
let signature = minisign_verify::Signature::decode(SIGNATURE).unwrap();
let mut reader = Chunked {
remaining: PAYLOAD,
chunk: 1,
interrupt: false,
fail_at_eof: true,
reads: 0,
};
assert!(verify_minisign_reader(&mut reader, &key, &signature).is_err());
assert_eq!(reader.reads, 0);
}
#[test]
fn minisign_stream_rejects_legacy_mode_without_reading_payload() {
let legacy = SIGNATURE.replace("\nRUTZ", "\nRWTZ");
let signature = minisign_verify::Signature::decode(&legacy).unwrap();
let key = minisign_verify::PublicKey::from_base64(TEST_KEY).unwrap();
let mut reader = Chunked {
remaining: PAYLOAD,
chunk: 1,
interrupt: false,
fail_at_eof: true,
reads: 0,
};
assert!(verify_minisign_reader(&mut reader, &key, &signature).is_err());
assert_eq!(reader.reads, 0);
}
#[test]
fn minisign_stream_authenticates_bytes_beyond_the_first_buffer() {
const KEY: &str = "RWRSQ0h0ZXN0MmBwaHoVCw4YFi8NW0uVH5/PWQ4eAfqB4KheU+mLK3vO";
const SIG: &str = "untrusted comment: rch stream test\n\
RURSQ0h0ZXN0Mi8mprmagbFizyF2eUefaE/5/jwR84KN9Yyf3OO1y5NwJP1Oe2voxuVRAyhv8L89lerNzNkvMbAIcENd/nq7KA0=\n\
trusted comment: rch bounded verification fixture\n\
Vcz52SNOvweQ+rHxeLsHlafJK6iWhND+nbrEl2yDfYyQfv/z3NEBaNwcXNt5QN5NQX3dPBymXHGFiMas/eJGCg==\n";
let key = minisign_verify::PublicKey::from_base64(KEY).unwrap();
let signature = minisign_verify::Signature::decode(SIG).unwrap();
let mut bytes = vec![0u8; 1024 * 1024 + 7];
let mut reader = Chunked {
remaining: &bytes,
chunk: usize::MAX,
interrupt: false,
fail_at_eof: false,
reads: 0,
};
verify_minisign_reader(&mut reader, &key, &signature).unwrap();
assert!(reader.reads > 16);
bytes[VERIFICATION_CHUNK_BYTES + 1] = 1;
assert!(verify_minisign_reader(&mut bytes.as_slice(), &key, &signature).is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_verify_sha256_bytes() {
let content = b"test content";
let expected = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[test]
fn test_verify_sha256_bytes_mismatch() {
let content = b"test content";
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
let result = verify_sha256_bytes(content, wrong);
assert!(matches!(result, Err(UpdateError::ChecksumMismatch { .. })));
}
#[tokio::test]
async fn test_compute_sha256() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let hash = compute_sha256(&file_path.to_path_buf()).await.unwrap();
assert_eq!(
hash,
"6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"
);
}
#[test]
fn test_verify_sha256_bytes_case_insensitive() {
let content = b"test content";
let expected_upper = "6AE8A75555209FD6C44157C0AED8016E763FF435A19CF186F76863140143FF72";
assert!(verify_sha256_bytes(content, expected_upper).is_ok());
}
#[test]
fn test_verify_sha256_bytes_empty_content() {
let content = b"";
let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[tokio::test]
async fn test_compute_sha256_large_file() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("large.bin");
let content = vec![0u8; 1024 * 1024];
std::fs::write(&file_path, &content).unwrap();
let hash = compute_sha256(&file_path.to_path_buf()).await.unwrap();
assert_eq!(
hash,
"30e14955ebf1352266dc2ff8067e68104607e750abb9d3b36582b8af909fcb58"
);
}
#[tokio::test]
async fn test_verify_checksum_sha256() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let expected = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
let result = verify_checksum(&file_path, expected).await.unwrap();
assert!(result.checksum_valid);
}
#[tokio::test]
async fn test_verify_checksum_mismatch() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
let result = verify_checksum(&file_path, wrong).await;
assert!(matches!(result, Err(UpdateError::ChecksumMismatch { .. })));
}
#[test]
fn test_verify_sha256_bytes_binary_content() {
let content: &[u8] = &[0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD];
let expected = "feb1aba6fea741741b1bbcc974f74fed337b535b8eec7223b6dd15d7108f08e3";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[tokio::test]
async fn test_verify_checksum_file_not_found() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("does_not_exist.bin");
let result = verify_checksum(&nonexistent, "0".repeat(64).as_str()).await;
assert!(matches!(
result,
Err(UpdateError::InstallFailed(msg)) if msg.contains("Failed to open file")
));
}
#[tokio::test]
async fn test_compute_sha256_file_not_found() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent.txt");
let result = compute_sha256(&nonexistent).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_checksum_blake3() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("blake3_test.txt");
std::fs::write(&file_path, "test content").unwrap();
let mut hasher = blake3::Hasher::new();
hasher.update(b"test content");
let blake3_hash = hasher.finalize().to_hex().to_string();
let result = verify_checksum(&file_path, &blake3_hash).await.unwrap();
assert!(result.checksum_valid);
}
#[test]
fn test_verification_result_fields() {
let result = VerificationResult {
checksum_valid: true,
signature_valid: Some(true),
};
assert!(result.checksum_valid);
assert_eq!(result.signature_valid, Some(true));
let result_no_sig = VerificationResult {
checksum_valid: false,
signature_valid: None,
};
assert!(!result_no_sig.checksum_valid);
assert!(result_no_sig.signature_valid.is_none());
}
#[test]
fn test_verify_sha256_bytes_invalid_hex_length() {
let content = b"test";
let short_hash = "abc123";
let result = verify_sha256_bytes(content, short_hash);
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_checksum_with_mixed_case_hex() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("mixedcase.txt");
std::fs::write(&file_path, "test content").unwrap();
let expected = "6Ae8A75555209fD6C44157c0AED8016E763Ff435a19cF186F76863140143Ff72";
let result = verify_checksum(&file_path, expected).await.unwrap();
assert!(result.checksum_valid);
}
#[tokio::test]
async fn test_compute_sha256_empty_file() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("empty.txt");
std::fs::write(&file_path, "").unwrap();
let hash = compute_sha256(&file_path).await.unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[tokio::test]
async fn test_signature_missing_bundle_yields_none() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("artifact.bin");
std::fs::write(&file_path, b"hello world").unwrap();
let mut hasher = sha2::Sha256::new();
use sha2::Digest;
hasher.update(b"hello world");
let expected = bytes_to_lower_hex(hasher.finalize());
let result = verify_checksum_and_signature(&file_path, &expected, None)
.await
.unwrap();
assert!(result.checksum_valid);
assert!(
result.signature_valid.is_none(),
"missing signature bundle must yield signature_valid=None, got {:?}",
result.signature_valid
);
}
#[tokio::test]
async fn test_signature_invalid_bundle_returns_err() {
if which::which("cosign").is_err() {
eprintln!("SKIP test_signature_invalid_bundle_returns_err: cosign not installed");
return;
}
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("artifact.bin");
let bundle_path = temp.path().join("artifact.sig");
std::fs::write(&file_path, b"hello world").unwrap();
std::fs::write(&bundle_path, b"\x00\x01\x02 totally not a real bundle").unwrap();
let result = verify_signature(&file_path, &bundle_path).await;
assert!(matches!(
result,
Err(UpdateError::SignatureVerificationFailed(_)) | Ok(false)
));
}
#[test]
fn test_rch_release_identity_pattern_is_anchored() {
assert!(
RCH_RELEASE_IDENTITY_PATTERN.starts_with('^'),
"identity pattern must start with ^"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.ends_with("$"),
"identity pattern must end with $"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"github\.com"),
"github.com dot must be escaped"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"\.github/"),
".github dot must be escaped"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"release\.yml"),
"release.yml dot must be escaped"
);
}
#[test]
fn test_rch_release_identity_pattern_accepts_canonical_url() {
let re = regex::Regex::new(RCH_RELEASE_IDENTITY_PATTERN).unwrap();
let canonical = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
re.is_match(canonical),
"canonical release URL should match: {}",
canonical
);
let branch_ref = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/heads/main";
assert!(re.is_match(branch_ref), "branch ref form should match");
}
#[test]
fn test_rch_release_identity_pattern_rejects_substring_attacks() {
let re = regex::Regex::new(RCH_RELEASE_IDENTITY_PATTERN).unwrap();
let attack1 = "https://github.com/Attacker/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack1),
"different-owner URL must not match: {}",
attack1
);
let attack2 = "https://github.com.evil.example/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack2),
"subdomain-prefix attack must not match: {}",
attack2
);
let attack3 = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/sneaky.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack3),
"different-workflow URL must not match: {}",
attack3
);
let attack4 = "http://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack4),
"http (not https) must not match: {}",
attack4
);
let attack5 = "evilhttps://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack5),
"lookalike-host prefix must not match: {}",
attack5
);
let attack6 = "https://github.com/Dicklesworthstone/remote_compilation_helper/Xgithub/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack6),
"missing .github literal must not match: {}",
attack6
);
}
#[tokio::test]
async fn integration_signature_verifies_with_real_cosign() {
if std::env::var("RCH_TEST_REAL_SIG").is_err() {
eprintln!(
"SKIP integration_signature_verifies_with_real_cosign: RCH_TEST_REAL_SIG not set"
);
return;
}
if which::which("cosign").is_err() {
eprintln!("SKIP integration_signature_verifies_with_real_cosign: cosign not in PATH");
return;
}
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let bin = manifest_dir.join("tests/fixtures/update/rch_release_sample.bin");
let sig = manifest_dir.join("tests/fixtures/update/rch_release_sample.sig");
if !bin.exists() || !sig.exists() {
eprintln!(
"SKIP integration_signature_verifies_with_real_cosign: fixtures missing at {} and/or {}",
bin.display(),
sig.display()
);
return;
}
let result = verify_signature(&bin, &sig).await;
assert!(
result.is_ok() && result.unwrap(),
"real cosign-signed binary should verify"
);
}
}
#[cfg(test)]
mod bounded_verifier_tests {
use super::*;
use std::time::Duration;
#[test]
fn cosign_preserves_exact_executable_paths_and_trust_policy_arguments() {
let executable = std::path::Path::new("/verifiers/cosign with spaces");
let archive = std::path::Path::new("/download/é ' archive.tar.gz");
let bundle = std::path::Path::new("/download/bundle;not-a-command.json");
let command = cosign_command(executable, archive, bundle);
assert_eq!(command.as_std().get_program(), executable.as_os_str());
let expected = [
std::ffi::OsStr::new("verify-blob"),
std::ffi::OsStr::new("--bundle"),
bundle.as_os_str(),
std::ffi::OsStr::new("--certificate-identity-regexp"),
std::ffi::OsStr::new(RCH_RELEASE_IDENTITY_PATTERN),
std::ffi::OsStr::new("--certificate-oidc-issuer"),
std::ffi::OsStr::new(GITHUB_ACTIONS_OIDC_ISSUER),
archive.as_os_str(),
];
assert_eq!(command.as_std().get_args().collect::<Vec<_>>(), expected);
}
#[tokio::test]
async fn verifier_output_accepts_exact_bound_and_refuses_one_extra_byte() {
for length in [0, 1, 8191, 8192, 8193, 65536] {
let bytes = vec![0xf0; length];
assert_eq!(
read_verifier_output(bytes.as_slice(), length)
.await
.unwrap(),
bytes,
);
if length > 0 {
assert!(
read_verifier_output(bytes.as_slice(), length - 1)
.await
.is_err()
);
}
}
}
#[cfg(unix)]
fn shell(script: &str) -> Command {
let mut command = Command::new("/bin/sh");
command.arg("-c").arg(script);
command
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_collects_both_streams_and_preserves_nonzero_status() {
let output = capture_verifier_output(
shell("printf out; printf err >&2; exit 7"),
Duration::from_secs(5),
64,
)
.await
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stdout, b"out");
assert_eq!(output.stderr, b"err");
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_has_no_interactive_stdin_and_requires_successful_exit() {
let output = capture_verifier_output(
shell("if read -r input; then exit 9; fi; printf verified"),
Duration::from_secs(5),
64,
)
.await
.unwrap();
assert!(output.status.success());
assert_eq!(output.stdout, b"verified");
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_bounds_each_output_stream_and_never_accepts_a_prefix() {
for script in [
"i=0; while [ $i -lt 100 ]; do printf 0123456789; i=$((i+1)); done",
"i=0; while [ $i -lt 100 ]; do printf 0123456789 >&2; i=$((i+1)); done",
] {
let error = capture_verifier_output(shell(script), Duration::from_secs(5), 64)
.await
.unwrap_err();
assert!(
error.to_string().contains("output limit exceeded"),
"{error}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_deadline_covers_a_silent_running_process() {
let error = capture_verifier_output(shell("exec sleep 30"), Duration::from_millis(30), 64)
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
assert!(!error.to_string().contains("teardown"), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_zero_exit_without_pipe_eof_is_not_verification_success() {
let error =
capture_verifier_output(shell("sleep 0.3 & exit 0"), Duration::from_millis(30), 64)
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
}
#[tokio::test]
async fn missing_verifier_is_an_error_not_a_successful_empty_exchange() {
let root = tempfile::tempdir().unwrap();
let error = capture_verifier_output(
Command::new(root.path().join("absent-verifier")),
Duration::from_secs(5),
64,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("Failed to execute cosign"),
"{error}"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn cancelling_verification_terminates_the_owned_local_child() {
let root = tempfile::tempdir().unwrap();
let pid_file = root.path().join("child.pid");
let mut command = shell("printf '%s' \"$$\" > \"$1\"; exec sleep 30");
command.arg("verifier-test").arg(&pid_file);
let task = tokio::spawn(capture_verifier_output(
command,
Duration::from_secs(30),
64,
));
let pid = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(text) = tokio::fs::read_to_string(&pid_file).await
&& let Ok(pid) = text.parse::<u32>()
{
break pid;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("test child did not become ready");
let stat_path = format!("/proc/{pid}/stat");
let initial = tokio::fs::read_to_string(&stat_path).await.unwrap();
let (_, fields) = initial.rsplit_once(") ").unwrap();
let start = fields.split_whitespace().nth(19).unwrap().to_owned();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
tokio::time::timeout(Duration::from_secs(3), async {
loop {
match tokio::fs::read_to_string(&stat_path).await {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) => panic!("cannot inspect owned test child: {error}"),
Ok(stat) => {
let (_, fields) = stat.rsplit_once(") ").unwrap();
let fields = fields.split_whitespace().collect::<Vec<_>>();
if matches!(fields[0], "Z" | "X") || fields[19] != start {
break;
}
}
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("cancelled verification left its local child running");
}
}