upstream-rs 1.4.2

Fetch package updates directly from the source.
use crate::{
    models::{
        common::enums::Provider,
        provider::{Asset, Release},
    },
    providers::provider_manager::ProviderManager,
};
use anyhow::{Result, anyhow};
use std::{
    fs,
    path::{Path, PathBuf},
};

#[derive(Clone, Copy, PartialEq, Eq)]
enum HashAlgo {
    Sha256,
    Sha512,
}

struct ChecksumEntry {
    algo: HashAlgo,
    filename: String,
    digest: String,
}

pub struct ChecksumVerifier<'a> {
    provider_manager: &'a ProviderManager,
    download_cache: &'a Path,
}

impl<'a> ChecksumVerifier<'a> {
    pub fn new(provider_manager: &'a ProviderManager, download_cache: &'a Path) -> Self {
        Self {
            provider_manager,
            download_cache,
        }
    }

    pub async fn try_verify_file<F>(
        &self,
        asset_path: &Path,
        release: &Release,
        provider: &Provider,
        dl_progress: &mut Option<F>,
    ) -> Result<bool>
    where
        F: FnMut(u64, u64),
    {
        let asset_filename = asset_path
            .file_name()
            .and_then(|n| n.to_str())
            .ok_or_else(|| anyhow!("Invalid asset filename"))?;

        // Try to download the checksum file
        let checksum_path = match self
            .try_download_checksum(release, asset_filename, provider, dl_progress)
            .await?
        {
            Some(path) => path,
            None => return Ok(false), // No checksum available, that's ok
        };

        // Read and parse the checksum file
        let contents = fs::read_to_string(&checksum_path)?;
        let entries = Self::parse_checksums(&contents);

        if entries.is_empty() {
            return Err(anyhow!("Checksum file is empty or invalid"));
        }

        let checksum_entry = if entries.len() == 1 && entries[0].filename.is_empty() {
            // Bare hash file - assume it's for the asset we're verifying
            &entries[0]
        } else {
            // Standard multi-entry checksum file
            entries
                .iter()
                .find(|entry| entry.filename == asset_filename)
                .or_else(|| {
                    // If exact match not found, try matching just the basename
                    entries.iter().find(|entry| {
                        Path::new(&entry.filename)
                            .file_name()
                            .and_then(|n| n.to_str())
                            == Some(asset_filename)
                    })
                })
                .ok_or_else(|| {
                    anyhow!(
                        "No checksum found for asset '{}' in checksum file",
                        asset_filename
                    )
                })?
        };

        // Verify the checksum
        Self::verify_checksum(asset_path, checksum_entry)
    }

    async fn try_download_checksum<F>(
        &self,
        release: &Release,
        asset_name: &str,
        provider: &Provider,
        dl_progress: &mut Option<F>,
    ) -> Result<Option<PathBuf>>
    where
        F: FnMut(u64, u64),
    {
        let checksum_asset = Self::find_checksum_asset(release, asset_name);

        let Some(asset) = checksum_asset else {
            return Ok(None); // no checksum advertised
        };

        // If this fails, it's a real error
        let path = self
            .provider_manager
            .download_asset(asset, provider, self.download_cache, dl_progress)
            .await?;

        Ok(Some(path))
    }

    fn find_checksum_asset<'r>(release: &'r Release, asset_name: &str) -> Option<&'r Asset> {
        let basename = Path::new(asset_name)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(asset_name);

        let specific_candidates = [
            format!("{asset_name}.sha256"),
            format!("{asset_name}.sha512"),
            format!("{basename}.sha256"),
            format!("{basename}.sha512"),
            format!("{basename}.sha256sum"),
            format!("{basename}.sha512sum"),
        ];

        for candidate in &specific_candidates {
            if let Some(asset) = release.get_asset_by_name_invariant(candidate) {
                return Some(asset);
            }
        }

        // Common release-level checksum files.
        const COMMON_NAMES: &[&str] = &[
            "checksums.txt",
            "checksums",
            "checksum.txt",
            "sha256sums.txt",
            "sha256sum.txt",
            "sha256sums",
            "sha256sum",
            "sha512sums.txt",
            "sha512sum.txt",
            "sha512sums",
            "sha512sum",
        ];
        for name in COMMON_NAMES {
            if let Some(asset) = release.get_asset_by_name_invariant(name) {
                return Some(asset);
            }
        }

        release
            .assets
            .iter()
            .find(|asset| Self::is_checksum_filename(&asset.name))
    }

    fn is_checksum_filename(name: &str) -> bool {
        let lowered = name.to_ascii_lowercase();
        lowered.ends_with(".sha256")
            || lowered.ends_with(".sha512")
            || lowered.ends_with(".sha256sum")
            || lowered.ends_with(".sha512sum")
            || lowered.ends_with(".sha256.txt")
            || lowered.ends_with(".sha512.txt")
            || lowered.ends_with(".sum")
            || lowered.contains("checksums")
    }

    fn parse_checksums(contents: &str) -> Vec<ChecksumEntry> {
        let mut entries = Vec::new();

        for line in contents.lines() {
            let line = line.trim();
            // Skip empty lines and comments
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            // Try to parse different checksum formats:
            // 1. "digest  filename" (two spaces, standard format)
            // 2. "digest *filename" (asterisk before filename, binary mode)
            // 3. "digest filename" (single space)
            // 4. "filename: digest" (colon-separated)
            // 5. "digest" (bare hash, no filename)
            if let Some(entry) = Self::parse_standard_format(line) {
                entries.push(entry);
            } else if let Some(entry) = Self::parse_colon_format(line) {
                entries.push(entry);
            } else if let Some(entry) = Self::parse_openssl_format(line) {
                entries.push(entry);
            } else if let Some(entry) = Self::parse_bare_hash(line) {
                entries.push(entry);
            }
        }

        entries
    }

    fn parse_digest(raw: &str) -> Option<(HashAlgo, String)> {
        let mut token = raw.trim();
        for prefix in ["sha256:", "sha256=", "sha512:", "sha512="] {
            if token.len() >= prefix.len() && token[..prefix.len()].eq_ignore_ascii_case(prefix) {
                token = &token[prefix.len()..];
                break;
            }
        }
        let token = token.trim();
        if !token.chars().all(|ch| ch.is_ascii_hexdigit()) {
            return None;
        }
        let algo = match token.len() {
            64 => HashAlgo::Sha256,
            128 => HashAlgo::Sha512,
            _ => return None,
        };

        Some((algo, token.to_ascii_lowercase()))
    }

    fn parse_standard_format(line: &str) -> Option<ChecksumEntry> {
        // Handle formats like:
        // "abc123  filename.tar.gz"
        // "abc123 *filename.tar.gz"
        // "abc123 filename.tar.gz"
        let parts: Vec<&str> = line.splitn(2, |c: char| c.is_whitespace()).collect();
        if parts.len() != 2 {
            return None;
        }

        let digest = parts[0].trim();
        let filename = parts[1].trim().trim_start_matches('*').trim();

        if digest.is_empty() || filename.is_empty() {
            return None;
        }

        let (algo, normalized) = Self::parse_digest(digest)?;

        Some(ChecksumEntry {
            algo,
            filename: filename.to_string(),
            digest: normalized,
        })
    }

    fn parse_colon_format(line: &str) -> Option<ChecksumEntry> {
        // Handle format like: "filename.tar.gz: abc123"
        let parts: Vec<&str> = line.splitn(2, ':').collect();
        if parts.len() != 2 {
            return None;
        }

        let filename = parts[0].trim();
        let digest = parts[1].trim();

        if digest.is_empty() || filename.is_empty() {
            return None;
        }

        let (algo, normalized) = Self::parse_digest(digest)?;

        Some(ChecksumEntry {
            algo,
            filename: filename.to_string(),
            digest: normalized,
        })
    }

    fn parse_openssl_format(line: &str) -> Option<ChecksumEntry> {
        let (left, right) = line.split_once('=')?;
        let left = left.trim();
        let open = left.find('(')?;
        let close = left.rfind(')')?;
        if close <= open + 1 {
            return None;
        }

        let algo_name = left[..open].trim();
        let expected_algo = if algo_name.eq_ignore_ascii_case("sha256") {
            HashAlgo::Sha256
        } else if algo_name.eq_ignore_ascii_case("sha512") {
            HashAlgo::Sha512
        } else {
            return None;
        };

        let filename = left[open + 1..close].trim();
        if filename.is_empty() {
            return None;
        }

        let (algo, normalized) = Self::parse_digest(right.trim())?;
        if algo != expected_algo {
            return None;
        }

        Some(ChecksumEntry {
            algo,
            filename: filename.to_string(),
            digest: normalized,
        })
    }

    fn parse_bare_hash(line: &str) -> Option<ChecksumEntry> {
        // Handle bare hash format (just the digest, no filename)
        let (algo, normalized) = Self::parse_digest(line.trim())?;

        // Use empty filename - indicates bare hash file
        Some(ChecksumEntry {
            algo,
            filename: String::new(),
            digest: normalized,
        })
    }

    fn verify_checksum(asset_path: &Path, checksum: &ChecksumEntry) -> Result<bool> {
        use std::io::{BufReader, Read};

        if !asset_path.exists() {
            return Err(anyhow!(
                "Asset file does not exist: {}",
                asset_path.display()
            ));
        }

        let file = fs::File::open(asset_path)?;
        let mut reader = BufReader::new(file);
        let mut buffer = [0u8; 8192]; // 8KB buffer

        let computed_digest = match checksum.algo {
            HashAlgo::Sha256 => {
                use sha2::Digest;
                let mut hasher = sha2::Sha256::new();
                loop {
                    let n = reader.read(&mut buffer)?;
                    if n == 0 {
                        break;
                    }
                    hasher.update(&buffer[..n]);
                }
                format!("{:x}", hasher.finalize())
            }
            HashAlgo::Sha512 => {
                use sha2::Digest;
                let mut hasher = sha2::Sha512::new();
                loop {
                    let n = reader.read(&mut buffer)?;
                    if n == 0 {
                        break;
                    }
                    hasher.update(&buffer[..n]);
                }
                format!("{:x}", hasher.finalize())
            }
        };

        Ok(computed_digest.to_lowercase() == checksum.digest.to_lowercase())
    }
}

#[cfg(test)]
#[path = "../../../tests/services/packaging/checksum_verifier.rs"]
mod tests;