fast-shlibdeps 0.2.2

A fast ELF shared library dependency analyzer for Debian-based systems
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::ops::Deref;
use std::path::Path;

const DPKG_INFO_DIR: &str = "/var/lib/dpkg/info";
const DPKG_STATUS_FILE: &str = "/var/lib/dpkg/status";

/// Find which packages own files by reading dpkg database directly.
/// Handles usr-merge by querying both with and without /usr prefix.
/// Returns a HashMap of path to package name.
pub(crate) fn find_packages_for_paths<'a>(
    paths: impl IntoIterator<Item = impl Deref<Target = &'a str>>,
) -> Result<HashMap<String, String>> {
    find_packages_for_paths_in_dir(paths, Path::new(DPKG_INFO_DIR))
}

/// Find packages for paths in a specific dpkg info directory (for testing)
fn find_packages_for_paths_in_dir<'a>(
    paths: impl IntoIterator<Item = impl Deref<Target = &'a str>>,
    info_dir: &Path,
) -> Result<HashMap<String, String>> {
    let mut path_to_package = HashMap::new();
    let mut search_paths = Vec::new();

    // Collect all paths we need to search for
    for path in paths {
        let path_str = *path;
        search_paths.push(path_str.to_string());
        // Also add /usr-prefixed version
        search_paths.push(format!("/usr{}", path_str));
    }

    // Read all .list files in the info directory
    if !info_dir.exists() {
        anyhow::bail!("dpkg info directory not found: {:?}", info_dir);
    }

    for entry in std::fs::read_dir(info_dir)? {
        let entry = entry?;
        let file_name = entry.file_name();
        let file_name_str = file_name.to_string_lossy();

        // Only process .list files
        if !file_name_str.ends_with(".list") {
            continue;
        }

        // Extract package name from filename (remove .list extension)
        let package_full = &file_name_str[..file_name_str.len() - 5];
        
        // Remove architecture suffix (e.g., :amd64, :i386)
        let package_name = if let Some(arch_pos) = package_full.rfind(':') {
            &package_full[..arch_pos]
        } else {
            package_full
        };

        // Read the list file and check if it contains any of our paths
        let file = File::open(entry.path())?;
        let reader = BufReader::new(file);

        for line in reader.lines() {
            let line = line?;
            let trimmed = line.trim();
            
            // Check if this line matches any of our search paths
            for search_path in &search_paths {
                if trimmed == search_path {
                    // Add the path as-is
                    path_to_package.insert(trimmed.to_string(), package_name.to_string());
                    
                    // If it starts with /usr, also add the version without /usr
                    if let Some(stripped) = trimmed.strip_prefix("/usr") {
                        path_to_package.insert(stripped.to_string(), package_name.to_string());
                    }
                    
                    // If it doesn't start with /usr, also check if we found the /usr version
                    if !trimmed.starts_with("/usr") {
                        let usr_version = format!("/usr{}", trimmed);
                        if search_paths.contains(&usr_version) {
                            path_to_package.insert(trimmed.to_string(), package_name.to_string());
                        }
                    }
                }
            }
        }
    }

    Ok(path_to_package)
}

/// Get the currently installed versions of packages by reading dpkg status file.
/// Returns a HashMap of package name to version for successfully queried packages.
pub(crate) fn get_package_versions<'a>(
    package_names: impl Iterator<Item = impl Deref<Target = &'a str>>,
) -> Result<HashMap<String, String>> {
    get_package_versions_from_file(package_names, Path::new(DPKG_STATUS_FILE))
}

/// Get package versions from a specific status file (for testing)
fn get_package_versions_from_file<'a>(
    package_names: impl Iterator<Item = impl Deref<Target = &'a str>>,
    status_file: &Path,
) -> Result<HashMap<String, String>> {
    let mut versions = HashMap::new();
    let target_packages: HashSet<String> = package_names
        .map(|name| (*name).to_string())
        .collect();

    if target_packages.is_empty() {
        return Ok(versions);
    }

    // Read the dpkg status file
    let file = File::open(status_file)?;
    let reader = BufReader::new(file);

    let mut current_package = String::new();
    let mut current_status = String::new();
    let mut current_version = String::new();
    let mut in_package = false;

    for line in reader.lines() {
        let line = line?;

        if line.is_empty() {
            // End of package entry
            if in_package && current_status.contains("install ok installed") && !current_version.is_empty() {
                versions.insert(current_package.clone(), current_version.clone());
            }
            current_package.clear();
            current_status.clear();
            current_version.clear();
            in_package = false;
            continue;
        }

        if let Some(package) = line.strip_prefix("Package: ") {
            current_package = package.to_string();
            in_package = target_packages.contains(&current_package);
        } else if in_package {
            if let Some(version) = line.strip_prefix("Version: ") {
                current_version = version.to_string();
            } else if let Some(status) = line.strip_prefix("Status: ") {
                current_status = status.to_string();
            }
        }
    }

    // Final package check (in case file doesn't end with empty line)
    if in_package && current_status.contains("install ok installed") && !current_version.is_empty() {
        versions.insert(current_package, current_version);
    }

    Ok(versions)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn setup_test_dpkg_info() -> TempDir {
        let temp_dir = TempDir::new().unwrap();
        let info_dir = temp_dir.path().join("info");
        fs::create_dir(&info_dir).unwrap();

        // Create test package list files
        fs::write(
            info_dir.join("test-package.list"),
            "/usr/bin/test-binary\n/usr/lib/libtest.so.1\n/lib/x86_64-linux-gnu/libtest.so.1\n",
        )
        .unwrap();

        fs::write(
            info_dir.join("another-package:amd64.list"),
            "/usr/bin/another\n/usr/share/doc/another-package\n",
        )
        .unwrap();

        fs::write(
            info_dir.join("libc6:amd64.list"),
            "/lib/x86_64-linux-gnu/libc.so.6\n/usr/lib/x86_64-linux-gnu/libc.so.6\n",
        )
        .unwrap();

        // Create a non-list file that should be ignored
        fs::write(info_dir.join("test.txt"), "should be ignored").unwrap();

        temp_dir
    }

    fn setup_test_dpkg_status() -> TempDir {
        let temp_dir = TempDir::new().unwrap();
        let status_content = r#"Package: libc6
Status: install ok installed
Version: 2.35-0ubuntu3.1
Architecture: amd64

Package: libssl3
Status: install ok installed
Version: 3.0.2-0ubuntu1.6
Architecture: amd64

Package: test-package
Status: install ok installed
Version: 1.2.3-4ubuntu5
Architecture: amd64

Package: not-installed
Status: deinstall ok config-files
Version: 0.0.1
Architecture: amd64

Package: another-package
Status: install ok installed
Version: 2.0.0
Architecture: amd64
"#;
        fs::write(temp_dir.path().join("status"), status_content).unwrap();
        temp_dir
    }

    #[test]
    fn test_find_packages_for_paths_basic() {
        let temp_dir = setup_test_dpkg_info();
        let info_dir = temp_dir.path().join("info");
        
        let paths = vec!["/usr/bin/test-binary", "/lib/x86_64-linux-gnu/libc.so.6"];
        let result = find_packages_for_paths_in_dir(paths.iter(), &info_dir).unwrap();
        
        assert_eq!(result.get("/usr/bin/test-binary").unwrap(), "test-package");
        assert_eq!(result.get("/lib/x86_64-linux-gnu/libc.so.6").unwrap(), "libc6");
    }

    #[test]
    fn test_find_packages_handles_usr_merge() {
        let temp_dir = setup_test_dpkg_info();
        let info_dir = temp_dir.path().join("info");
        
        let paths = vec!["/lib/x86_64-linux-gnu/libtest.so.1"];
        let result = find_packages_for_paths_in_dir(paths.iter(), &info_dir).unwrap();
        
        // Should find the package even though we're searching for /lib path
        assert_eq!(result.get("/lib/x86_64-linux-gnu/libtest.so.1").unwrap(), "test-package");
        // Should also have the non-/usr version
        assert_eq!(result.get("lib/x86_64-linux-gnu/libtest.so.1"), None);
    }

    #[test]
    fn test_find_packages_strips_architecture() {
        let temp_dir = setup_test_dpkg_info();
        let info_dir = temp_dir.path().join("info");
        
        let paths = vec!["/usr/bin/another"];
        let result = find_packages_for_paths_in_dir(paths.iter(), &info_dir).unwrap();
        
        // Should strip :amd64 from package name
        assert_eq!(result.get("/usr/bin/another").unwrap(), "another-package");
    }

    #[test]
    fn test_find_packages_empty_paths() {
        let temp_dir = setup_test_dpkg_info();
        let info_dir = temp_dir.path().join("info");
        
        let paths: Vec<&str> = vec![];
        let result = find_packages_for_paths_in_dir(paths.iter(), &info_dir).unwrap();
        
        assert!(result.is_empty());
    }

    #[test]
    fn test_find_packages_nonexistent_paths() {
        let temp_dir = setup_test_dpkg_info();
        let info_dir = temp_dir.path().join("info");
        
        let paths = vec!["/nonexistent/path", "/another/missing/file"];
        let result = find_packages_for_paths_in_dir(paths.iter(), &info_dir).unwrap();
        
        assert!(result.is_empty());
    }

    #[test]
    fn test_get_package_versions_basic() {
        let temp_dir = setup_test_dpkg_status();
        let status_file = temp_dir.path().join("status");
        
        let packages = vec!["libc6", "libssl3", "test-package"];
        let result = get_package_versions_from_file(packages.iter(), &status_file).unwrap();
        
        assert_eq!(result.get("libc6").unwrap(), "2.35-0ubuntu3.1");
        assert_eq!(result.get("libssl3").unwrap(), "3.0.2-0ubuntu1.6");
        assert_eq!(result.get("test-package").unwrap(), "1.2.3-4ubuntu5");
    }

    #[test]
    fn test_get_package_versions_not_installed() {
        let temp_dir = setup_test_dpkg_status();
        let status_file = temp_dir.path().join("status");
        
        let packages = vec!["not-installed"];
        let result = get_package_versions_from_file(packages.iter(), &status_file).unwrap();
        
        // Should not include packages that aren't installed
        assert!(!result.contains_key("not-installed"));
    }

    #[test]
    fn test_get_package_versions_mixed() {
        let temp_dir = setup_test_dpkg_status();
        let status_file = temp_dir.path().join("status");
        
        let packages = vec!["libc6", "not-installed", "nonexistent", "another-package"];
        let result = get_package_versions_from_file(packages.iter(), &status_file).unwrap();
        
        assert_eq!(result.len(), 2);
        assert_eq!(result.get("libc6").unwrap(), "2.35-0ubuntu3.1");
        assert_eq!(result.get("another-package").unwrap(), "2.0.0");
        assert!(!result.contains_key("not-installed"));
        assert!(!result.contains_key("nonexistent"));
    }

    #[test]
    fn test_get_package_versions_empty() {
        let temp_dir = setup_test_dpkg_status();
        let status_file = temp_dir.path().join("status");
        
        let packages: Vec<&str> = vec![];
        let result = get_package_versions_from_file(packages.iter(), &status_file).unwrap();
        
        assert!(result.is_empty());
    }

}