knott 0.1.14

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
use crate::cli::Options;
use crate::output;
use crate::pacman::Toolchain;
use crate::transaction::Transaction;

use anyhow::Result;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

const DEFAULT_COUNTRY: &str = "United States";

pub async fn update(
    options: &Options,
    tools: &Toolchain,
    tx: Option<&Transaction>,
    args: &[String],
) -> Result<i32> {
    let (country, explicit_keys) = split_country_and_keys(args);
    output::status("Mirror country", &country);

    for key in short_key_ids(args) {
        output::warning(format!(
            "ignoring short or ambiguous PGP key id '{key}'; use a 16+ character key id or full fingerprint"
        ));
    }

    let aur_keys = collect_aur_keys(tx);
    let mut keys = explicit_keys.into_iter().collect::<BTreeSet<_>>();
    keys.extend(aur_keys);
    let keys = keys.into_iter().collect::<Vec<_>>();

    if keys.is_empty() {
        output::status("AUR PGP keys", "none found");
    } else {
        output::status("AUR PGP keys", output::join_inline(&keys, " "));
        let code = tools.import_gpg_keys(&keys, options.dry_run).await?;
        if code != 0 {
            return Ok(code);
        }
    }

    output::status(
        "Mirror policy",
        "HTTPS, fresh, complete, 5s timeouts, speed-test 50 and keep fastest 10",
    );
    output::status("Arch keyring", "sync archlinux-keyring first");
    let packages = ["archlinux-keyring".to_string(), "reflector".to_string()];
    let code = tools
        .install_keyring_packages(&packages, options.no_confirm, options.dry_run)
        .await?;
    if code != 0 {
        return Ok(code);
    }

    output::status("Arch keyring", "initialize pacman keyring if needed");
    let code = tools.init_pacman_keys(options.dry_run).await?;
    if code != 0 {
        return Ok(code);
    }

    output::status("Arch keyring", "populate archlinux");
    let code = tools.populate_pacman_keys(options.dry_run).await?;
    if code != 0 {
        return Ok(code);
    }

    output::status("Arch keyring", "update trust database");
    let code = tools.update_pacman_key_database(options.dry_run).await?;
    if code != 0 {
        return Ok(code);
    }

    output::status("Mirrors", "speed-testing, ranking, and saving mirrorlist");
    let code = tools.update_mirrors(&country, options.dry_run).await?;
    if code != 0 {
        return Ok(code);
    }

    output::status("System update", "force refresh databases and upgrade");
    tools
        .force_system_upgrade(options.no_confirm, options.dry_run)
        .await
}

fn collect_aur_keys(tx: Option<&Transaction>) -> Vec<String> {
    let mut keys = BTreeSet::new();
    let Some(tx) = tx else {
        return Vec::new();
    };

    for item in &tx.aur_items {
        keys.extend(valid_pgp_keys_from_build_dir(&item.build_dir));
    }

    keys.into_iter().collect()
}

fn valid_pgp_keys_from_build_dir(dir: &Path) -> Vec<String> {
    let srcinfo = dir.join(".SRCINFO");
    if let Ok(data) = fs::read_to_string(&srcinfo) {
        return valid_pgp_keys_from_srcinfo(&data);
    }

    let pkgbuild = dir.join("PKGBUILD");
    fs::read_to_string(pkgbuild)
        .map(|data| valid_pgp_keys_from_pkgbuild(&data))
        .unwrap_or_default()
}

pub(crate) fn valid_pgp_keys_from_srcinfo(data: &str) -> Vec<String> {
    data.lines()
        .filter_map(|line| line.trim().strip_prefix("validpgpkeys = "))
        .map(str::trim)
        .filter_map(normalize_key)
        .collect()
}

pub(crate) fn valid_pgp_keys_from_pkgbuild(data: &str) -> Vec<String> {
    let Some(start) = data.find("validpgpkeys=(") else {
        return Vec::new();
    };
    let rest = &data[start + "validpgpkeys=(".len()..];
    let Some(end) = rest.find(')') else {
        return Vec::new();
    };

    rest[..end]
        .split_whitespace()
        .map(|value| value.trim_matches(|ch| ch == '\'' || ch == '"'))
        .filter_map(normalize_key)
        .collect()
}

pub(crate) fn split_country_and_keys(args: &[String]) -> (String, Vec<String>) {
    if args.is_empty() {
        return (DEFAULT_COUNTRY.to_string(), Vec::new());
    }

    let Some(first_key) = args
        .iter()
        .position(|arg| looks_like_key(arg) || looks_like_short_key(arg))
    else {
        return (normalize_country(&args.join(" ")), Vec::new());
    };

    if first_key == 0 {
        return (
            DEFAULT_COUNTRY.to_string(),
            args.iter().filter_map(|arg| normalize_key(arg)).collect(),
        );
    }

    (
        normalize_country(&args[..first_key].join(" ")),
        args[first_key..]
            .iter()
            .filter_map(|arg| normalize_key(arg))
            .collect(),
    )
}

fn normalize_country(value: &str) -> String {
    if value.eq_ignore_ascii_case("usa") || value.eq_ignore_ascii_case("us") {
        DEFAULT_COUNTRY.to_string()
    } else {
        value.to_string()
    }
}

fn looks_like_key(value: &str) -> bool {
    normalize_key(value).is_some()
}

fn short_key_ids(args: &[String]) -> Vec<String> {
    args.iter()
        .filter(|arg| looks_like_short_key(arg))
        .cloned()
        .collect()
}

fn looks_like_short_key(value: &str) -> bool {
    let value = key_body(value);
    (8..16).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}

fn normalize_key(value: &str) -> Option<String> {
    let value = key_body(value);
    if value.len() < 16 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return None;
    }

    Some(value.to_ascii_uppercase())
}

fn key_body(value: &str) -> &str {
    value
        .trim()
        .strip_prefix("0x")
        .or_else(|| value.trim().strip_prefix("0X"))
        .unwrap_or_else(|| value.trim())
}

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

    fn strings(values: &[&str]) -> Vec<String> {
        values.iter().map(|value| (*value).to_string()).collect()
    }

    #[test]
    fn splits_country_and_keys() {
        assert_eq!(
            split_country_and_keys(&[]),
            ("United States".into(), Vec::new())
        );
        assert_eq!(
            split_country_and_keys(&strings(&["Germany"])),
            ("Germany".into(), Vec::new())
        );
        assert_eq!(
            split_country_and_keys(&strings(&["ABCD1234"])),
            ("United States".into(), Vec::new())
        );
        assert_eq!(
            split_country_and_keys(&strings(&["South", "Korea"])),
            ("South Korea".into(), Vec::new())
        );
        assert_eq!(
            split_country_and_keys(&strings(&["United", "States", "432705FACDD40325"])),
            ("United States".into(), strings(&["432705FACDD40325"]))
        );
        assert_eq!(
            split_country_and_keys(&strings(&["usa", "432705FACDD40325"])),
            ("United States".into(), strings(&["432705FACDD40325"]))
        );
        assert_eq!(
            split_country_and_keys(&strings(&["0x432705FACDD40325"])),
            ("United States".into(), strings(&["432705FACDD40325"]))
        );
    }

    #[test]
    fn parses_validpgpkeys_from_srcinfo() {
        let data = "pkgbase = foo\n\tvalidpgpkeys = ABCD1234\n\tvalidpgpkeys = 432705FACDD40325\n";
        assert_eq!(
            valid_pgp_keys_from_srcinfo(data),
            strings(&["432705FACDD40325"])
        );
    }

    #[test]
    fn parses_validpgpkeys_from_pkgbuild() {
        let data = "pkgname=foo\nvalidpgpkeys=('ABCD1234' \"0x432705FACDD40325\")\n";
        assert_eq!(
            valid_pgp_keys_from_pkgbuild(data),
            strings(&["432705FACDD40325"])
        );
    }
}