pray-core 1.13.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use std::io::{IsTerminal, Write};
use std::path::Path;

use crate::terminal::no_input_requested;
use crate::{PrayError, PrayResult};

use super::enforce::{env_truthy, signer_matches_allowed};
use super::git::{repository_signing_keys, trust_git_output};
use super::policy::{best_rule, keys_missing_for_trust_scope, load_policy_or_default};

#[derive(Debug, Clone)]
struct HeadAssessment {
    commit: String,
    short_commit: String,
    author_name: String,
    author_email: String,
    authored_at: String,
    subject: String,
    signature_status: String,
    signature_signer: String,
    signature_key: String,
    signature_fingerprint: String,
}

pub fn prompt_import_signing_keys_for_source(
    home: &Path,
    source_url: &str,
    repository: &Path,
    global_scope: bool,
) -> PrayResult<()> {
    let keys = repository_signing_keys(home, source_url, repository);
    if keys.is_empty() {
        eprintln!(
            "[pray][trust] no signer key/fingerprint found on HEAD (nothing to import): {}",
            repository.display()
        );
        return Ok(());
    }
    let missing = keys_missing_for_trust_scope(home, source_url, &keys, global_scope)?;
    if missing.is_empty() {
        eprintln!("[pray][trust] signer key already trusted for {source_url}");
        return Ok(());
    }

    if no_input_requested() {
        return Err(PrayError::Usage(format!(
            "--trust requires interactive consent; remove --no-input or run in a terminal for {source_url}"
        )));
    }

    if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
        return Err(PrayError::Unsupported(format!(
            "--trust requires an interactive terminal to confirm signer key import for {source_url}"
        )));
    }

    print_head_assessment(home, source_url, repository, global_scope, &missing, true)?;
    let mut stderr = std::io::stderr();
    write!(
        stderr,
        "[pray][trust] add these keys to trust policy? [y/N]: "
    )?;
    stderr.flush()?;

    if !confirmed_yes()? {
        eprintln!("[pray][trust] declined key import for {source_url}");
        return Ok(());
    }

    for key in &missing {
        super::commands::add_allowed_signing_key(
            home,
            key,
            if global_scope { None } else { Some(source_url) },
        )?;
    }
    eprintln!(
        "[pray][trust] imported {} key(s) for {}",
        missing.len(),
        source_url
    );
    Ok(())
}

pub fn prompt_untrusted_source_consent(
    home: &Path,
    source_url: &str,
    repository: &Path,
) -> PrayResult<()> {
    let policy = load_policy_or_default(home)?;
    let rule = best_rule(&policy, source_url);
    if !rule.allow {
        return Ok(());
    }

    let assessment = head_assessment(home, source_url, repository);
    let signature_status = assessment
        .as_ref()
        .map(|value| value.signature_status.as_str())
        .unwrap_or_default();
    let good_signature = signature_status == "G";
    let trusted_signer = signer_matches_allowed(home, source_url, rule, repository);
    if good_signature || trusted_signer {
        return Ok(());
    }

    if env_truthy("PRAY_TRUST_ASSUME_YES") {
        eprintln!(
            "[pray][trust] auto-consent enabled via PRAY_TRUST_ASSUME_YES for untrusted source {source_url}"
        );
        return Ok(());
    }

    if no_input_requested() {
        return Err(PrayError::Usage(format!(
            "untrusted source requires interactive consent; remove --no-input to continue with {source_url}"
        )));
    }

    if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
        return Err(PrayError::Integrity(format!(
            "untrusted source requires interactive consent (no verified signature/trusted signer): {source_url}"
        )));
    }

    eprintln!("[pray][trust] untrusted source assessment");
    print_head_assessment(home, source_url, repository, false, &[], false)?;
    eprintln!(
        "[pray][trust] reason: source has no verified-good signature and signer is not trusted in policy"
    );

    let mut stderr = std::io::stderr();
    write!(
        stderr,
        "[pray][trust] continue with this untrusted source? [y/N]: "
    )?;
    stderr.flush()?;

    if !confirmed_yes()? {
        return Err(PrayError::Integrity(format!(
            "installation aborted by user for untrusted source {source_url}"
        )));
    }
    Ok(())
}

fn print_head_assessment(
    home: &Path,
    source_url: &str,
    repository: &Path,
    global_scope: bool,
    proposed_keys: &[String],
    import_prompt: bool,
) -> PrayResult<()> {
    eprintln!("[pray][trust] source: {source_url}");
    eprintln!("[pray][trust] repo: {}", repository.display());
    eprintln!(
        "[pray][trust] policy scope: {}",
        if global_scope {
            "<default/global>"
        } else {
            "<source-specific>"
        }
    );
    if let Some(assessment) = head_assessment(home, source_url, repository) {
        eprintln!(
            "[pray][trust] head: {} ({})",
            assessment.short_commit,
            if assessment.commit.is_empty() {
                "-"
            } else {
                &assessment.commit
            }
        );
        eprintln!(
            "[pray][trust] author: {} <{}>",
            assessment.author_name, assessment.author_email
        );
        eprintln!("[pray][trust] date: {}", assessment.authored_at);
        eprintln!("[pray][trust] subject: {}", assessment.subject);
        if !assessment.signature_status.is_empty() {
            eprintln!(
                "[pray][trust] signature: {} ({})",
                signature_status_human(&assessment.signature_status),
                assessment.signature_status
            );
        } else if !import_prompt {
            eprintln!("[pray][trust] signature: unknown");
        }
        if !assessment.signature_signer.is_empty() {
            eprintln!("[pray][trust] signer: {}", assessment.signature_signer);
        }
        if !assessment.signature_key.is_empty() {
            eprintln!("[pray][trust] signer key id: {}", assessment.signature_key);
        }
        if !assessment.signature_fingerprint.is_empty() {
            eprintln!(
                "[pray][trust] signer fingerprint: {}",
                assessment.signature_fingerprint
            );
        }
    } else if !import_prompt {
        eprintln!("[pray][trust] unable to read HEAD signing metadata");
    }
    if import_prompt {
        eprintln!("[pray][trust] proposed keys:");
        for key in proposed_keys {
            eprintln!("[pray][trust]   - {key}");
        }
    }
    Ok(())
}

fn head_assessment(home: &Path, source_url: &str, repository: &Path) -> Option<HeadAssessment> {
    let format = "%H%n%h%n%an%n%ae%n%aI%n%s%n%G?%n%GS%n%GK%n%GF";
    let raw = trust_git_output(
        home,
        source_url,
        repository,
        &["log", "-1", &format!("--format={format}")],
    )
    .ok()?;
    let mut lines = raw.lines();
    Some(HeadAssessment {
        commit: lines.next().unwrap_or_default().to_string(),
        short_commit: lines.next().unwrap_or_default().to_string(),
        author_name: lines.next().unwrap_or_default().to_string(),
        author_email: lines.next().unwrap_or_default().to_string(),
        authored_at: lines.next().unwrap_or_default().to_string(),
        subject: lines.next().unwrap_or_default().to_string(),
        signature_status: lines.next().unwrap_or_default().to_string(),
        signature_signer: lines.next().unwrap_or_default().to_string(),
        signature_key: lines.next().unwrap_or_default().to_string(),
        signature_fingerprint: lines.next().unwrap_or_default().to_string(),
    })
}

fn signature_status_human(code: &str) -> &'static str {
    match code {
        "G" => "good signature",
        "U" => "good signature (untrusted key)",
        "B" => "bad signature",
        "N" => "no signature",
        "E" => "signature verification error",
        _ => "unknown signature state",
    }
}

fn confirmed_yes() -> PrayResult<bool> {
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    Ok(matches!(
        input.trim().to_ascii_lowercase().as_str(),
        "y" | "yes"
    ))
}