vtcode 0.141.5

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use std::io::Write;
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;

use super::github::{self, ReleaseAsset};

pub(super) async fn download_asset(
    asset: &ReleaseAsset,
    destination: &Path,
    timeout: Duration,
    show_progress: bool,
) -> Result<()> {
    let response = response_for_url(&asset.download_url, timeout).await?;
    let response = response
        .error_for_status()
        .with_context(|| format!("GitHub asset download returned an error for {}", asset.name))?;
    let total = response.content_length();
    let mut stream = response;
    let mut file = tokio::fs::File::create(destination)
        .await
        .with_context(|| format!("failed to create downloaded archive {}", destination.display()))?;
    let mut downloaded = 0_u64;

    while let Some(chunk) = stream
        .chunk()
        .await
        .with_context(|| format!("failed while downloading {}", asset.name))?
    {
        file.write_all(&chunk)
            .await
            .with_context(|| format!("failed to write downloaded archive {}", destination.display()))?;
        downloaded += chunk.len() as u64;
        if show_progress {
            print_progress(&asset.name, downloaded, total);
        }
    }
    file.flush().await.context("failed to flush downloaded archive")?;
    if show_progress {
        println!();
    }
    Ok(())
}

pub(super) async fn download_checksum(asset: &ReleaseAsset, timeout: Duration) -> Result<String> {
    let response = response_for_url(&asset.download_url, timeout).await?;
    response
        .error_for_status()
        .with_context(|| format!("checksum download returned an error for {}", asset.name))?
        .text()
        .await
        .with_context(|| format!("failed to read checksum metadata {}", asset.name))
}

async fn response_for_url(url: &str, timeout: Duration) -> Result<reqwest::Response> {
    let response = github::github_client()?
        .get(url)
        .timeout(timeout)
        .header(reqwest::header::ACCEPT, "application/octet-stream")
        .send()
        .await
        .with_context(|| format!("failed to download update asset from {url}"));

    match response {
        Ok(response) if response.status() == reqwest::StatusCode::UNAUTHORIZED && github::github_token().is_some() => {
            github::unauthenticated_client()?
                .get(url)
                .timeout(timeout)
                .header(reqwest::header::ACCEPT, "application/octet-stream")
                .send()
                .await
                .with_context(|| format!("failed to download update asset from {url}"))
        }
        Ok(response) => Ok(response),
        Err(error) => Err(error),
    }
}

pub(super) fn checksum_asset<'a>(assets: &'a [ReleaseAsset], archive_name: &str) -> Option<&'a ReleaseAsset> {
    let archive_name = archive_name.to_ascii_lowercase();
    let exact_name = format!("{archive_name}.sha256");
    assets
        .iter()
        .find(|asset| asset.name.to_ascii_lowercase() == exact_name)
        .or_else(|| {
            assets.iter().find(|asset| {
                let name = asset.name.to_ascii_lowercase();
                name == "checksums.txt" || name == "sha256sums.txt"
            })
        })
}

pub(super) fn parse_checksum_metadata(metadata: &str, archive_name: &str) -> Option<String> {
    let archive_name = archive_name.to_ascii_lowercase();
    metadata.lines().find_map(|line| {
        let tokens: Vec<_> = line.split_whitespace().collect();
        let digest = tokens.iter().find(|token| is_sha256(token))?;
        if tokens.len() == 1 || line.to_ascii_lowercase().contains(&archive_name) {
            Some(digest.to_ascii_lowercase())
        } else {
            None
        }
    })
}

pub(super) fn verify_checksum(contents: &[u8], expected: &str) -> Result<()> {
    if !is_sha256(expected) {
        bail!("invalid SHA-256 checksum metadata");
    }
    let actual = digest_hex(contents);
    if actual != expected.to_ascii_lowercase() {
        bail!("checksum mismatch: expected {expected}, got {actual}");
    }
    Ok(())
}

pub(super) fn verify_file_checksum(path: &Path, expected: &str) -> Result<()> {
    let contents =
        std::fs::read(path).with_context(|| format!("failed to read {} for checksum verification", path.display()))?;
    verify_checksum(&contents, expected)
}

fn is_sha256(value: &str) -> bool {
    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}

fn digest_hex(contents: &[u8]) -> String {
    Sha256::digest(contents).iter().map(|byte| format!("{byte:02x}")).collect()
}

fn print_progress(name: &str, downloaded: u64, total: Option<u64>) {
    let mut output = std::io::stdout().lock();
    match total {
        Some(total) if total > 0 => {
            let percent = downloaded.saturating_mul(100) / total;
            let _ = write!(output, "\rDownloading {name}: {percent}% ({downloaded}/{total} bytes)");
        }
        _ => {
            let _ = write!(output, "\rDownloading {name}: {downloaded} bytes");
        }
    }
    let _ = output.flush();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_checksum_for_named_archive() {
        let checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef  vtcode-1.0.0.tar.gz\n";
        assert_eq!(
            parse_checksum_metadata(checksum, "vtcode-1.0.0.tar.gz"),
            Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string())
        );
    }

    #[test]
    fn ignores_checksum_for_another_archive() {
        let checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef  other.tar.gz\n";
        assert!(parse_checksum_metadata(checksum, "vtcode.tar.gz").is_none());
    }

    #[test]
    fn verifies_checksum_and_rejects_mismatch() {
        let expected = digest_hex(b"archive");
        verify_checksum(b"archive", &expected).expect("checksum");
        let error = verify_checksum(b"tampered", &expected).expect_err("mismatch");
        assert!(error.to_string().contains("checksum mismatch"));
    }

    #[test]
    fn missing_checksum_metadata_is_optional() {
        assert!(parse_checksum_metadata("signature only", "vtcode.tar.gz").is_none());
    }

    #[test]
    fn ignores_unrelated_architecture_checksum_sidecars() {
        let assets = vec![
            ReleaseAsset {
                name: "vtcode-1.0.0-aarch64-apple-darwin.tar.gz".to_string(),
                download_url: "https://example.test/archive".to_string(),
            },
            ReleaseAsset {
                name: "vtcode-1.0.0-x86_64-apple-darwin.tar.gz.sha256".to_string(),
                download_url: "https://example.test/checksum".to_string(),
            },
        ];

        assert!(checksum_asset(&assets, "vtcode-1.0.0-aarch64-apple-darwin.tar.gz").is_none());
    }

    #[tokio::test]
    async fn download_reports_http_failures_with_asset_context() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
        let address = listener.local_addr().expect("address");
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("connection");
            let mut request = [0_u8; 256];
            let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut request).await;
            stream
                .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
                .await
                .expect("response");
        });

        let asset = ReleaseAsset {
            name: "vtcode.tar.gz".to_string(),
            download_url: format!("http://{address}/missing"),
        };
        let temp = tempfile::tempdir().expect("temp");
        let error = download_asset(&asset, &temp.path().join("archive"), Duration::from_secs(1), false)
            .await
            .expect_err("http error");

        assert!(error.to_string().contains("vtcode.tar.gz"));
    }

    #[tokio::test]
    async fn download_reports_timeout_with_url_context() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
        let address = listener.local_addr().expect("address");
        tokio::spawn(async move {
            let _ = listener.accept().await;
            tokio::time::sleep(Duration::from_secs(1)).await;
        });

        let error = response_for_url(&format!("http://{address}/slow"), Duration::from_millis(20))
            .await
            .expect_err("timeout");

        assert!(error.to_string().contains("failed to download update asset"));
    }
}