parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Observability-status computation from a slice of `ContributingFinding` values.
//!
//! All logic is pure — no I/O, no side effects. Separated from `verdict_builder` so
//! unit and property tests can construct inputs directly.

use crate::{
    BlockFamily, BlockSummary, ContributingFinding, ObservabilityStatus, StrategyOutcomeKind,
};

/// Minimum observation opportunities required before classification is meaningful.
const MIN_OPPORTUNITIES: usize = 3;
/// Blocked-fraction threshold for `BlockedBeforeOracleLayer`.
const BLOCKED_THRESHOLD: f64 = 0.80;
/// Blocked-fraction lower bound for `PartiallyBlocked`.
const PARTIALLY_BLOCKED_LOWER: f64 = 0.20;

/// Computes `ObservabilityStatus` and `BlockSummary` from a set of `ContributingFinding`s.
///
/// Call after building contributing findings so block families are populated.
#[must_use]
pub fn compute_observability(
    findings: &[ContributingFinding],
) -> (ObservabilityStatus, Option<BlockSummary>) {
    let evidence_count = count_evidence_bearing(findings);
    if evidence_count >= 1 {
        return (ObservabilityStatus::EvidenceObserved, None);
    }

    let opportunities = count_expected_opportunities(findings);
    if opportunities < MIN_OPPORTUNITIES {
        return (ObservabilityStatus::Underpowered, None);
    }

    let (scan_wide_blocked, surface_blocked) = count_blocked(findings);
    #[allow(clippy::cast_precision_loss)]
    let blocked_fraction = scan_wide_blocked as f64 / opportunities as f64;
    let dominant = dominant_scan_wide_family(findings);

    if surface_blocked > 0 && scan_wide_blocked == 0 {
        return (ObservabilityStatus::SurfaceMismatch, None);
    }

    if blocked_fraction >= BLOCKED_THRESHOLD
        && matches!(
            dominant,
            Some(BlockFamily::Authorization | BlockFamily::Method)
        )
    {
        let summary = build_block_summary(findings, opportunities, scan_wide_blocked, dominant);
        return (ObservabilityStatus::BlockedBeforeOracleLayer, Some(summary));
    }

    let reached_fraction = 1.0 - blocked_fraction;
    if reached_fraction >= BLOCKED_THRESHOLD {
        return (ObservabilityStatus::ProbedNoEvidence, None);
    }

    if blocked_fraction >= PARTIALLY_BLOCKED_LOWER {
        let summary = build_block_summary(findings, opportunities, scan_wide_blocked, dominant);
        return (ObservabilityStatus::PartiallyBlocked, Some(summary));
    }

    (ObservabilityStatus::ProbedNoEvidence, None)
}

/// Positive + Contradictory findings that carry evidence.
fn count_evidence_bearing(findings: &[ContributingFinding]) -> usize {
    findings
        .iter()
        .filter(|f| {
            matches!(
                f.outcome_kind,
                StrategyOutcomeKind::Positive | StrategyOutcomeKind::Contradictory
            )
        })
        .count()
}

/// Findings that could have observed the oracle layer: `Inapplicable` only.
///
/// Only reachable when `evidence_bearing_count == 0`, so `Contradictory` findings
/// (which are evidence-bearing and short-circuit in the caller) are never present here.
fn count_expected_opportunities(findings: &[ContributingFinding]) -> usize {
    findings
        .iter()
        .filter(|f| f.outcome_kind == StrategyOutcomeKind::Inapplicable)
        .count()
}

/// Scan-wide blocked count (Authorization + Method) and surface-mismatch count.
fn count_blocked(findings: &[ContributingFinding]) -> (usize, usize) {
    let mut scan_wide = 0usize;
    let mut surface = 0usize;
    for f in findings {
        if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
            continue;
        }
        match f.block_family {
            Some(BlockFamily::Authorization | BlockFamily::Method) => scan_wide += 1,
            Some(BlockFamily::Surface) => surface += 1,
            _ => {}
        }
    }
    (scan_wide, surface)
}

/// Returns the most common scan-wide block family among `Inapplicable` findings.
fn dominant_scan_wide_family(findings: &[ContributingFinding]) -> Option<BlockFamily> {
    let mut auth_count = 0usize;
    let mut method_count = 0usize;
    for f in findings {
        if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
            continue;
        }
        match f.block_family {
            Some(BlockFamily::Authorization) => auth_count += 1,
            Some(BlockFamily::Method) => method_count += 1,
            _ => {}
        }
    }
    if auth_count == 0 && method_count == 0 {
        return None;
    }
    if auth_count >= method_count {
        Some(BlockFamily::Authorization)
    } else {
        Some(BlockFamily::Method)
    }
}

fn build_block_summary(
    findings: &[ContributingFinding],
    opportunities: usize,
    blocked: usize,
    dominant: Option<BlockFamily>,
) -> BlockSummary {
    let dominant_family = dominant.unwrap_or(BlockFamily::Authorization);
    let reasons = collect_dominant_reasons(findings, dominant_family);
    let operator_action = derive_operator_action(dominant_family, &reasons);
    #[allow(clippy::cast_precision_loss)]
    let blocked_fraction = if opportunities > 0 {
        blocked as f64 / opportunities as f64
    } else {
        0.0
    };
    BlockSummary {
        expected_observation_opportunities: opportunities,
        blocked_before_oracle_layer: blocked,
        blocked_fraction,
        dominant_block_family: dominant_family,
        dominant_block_reasons: reasons,
        operator_action,
    }
}

/// Collects distinct reason strings for the dominant block family.
fn collect_dominant_reasons(findings: &[ContributingFinding], family: BlockFamily) -> Vec<String> {
    let mut seen = std::collections::BTreeSet::new();
    for f in findings {
        if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
            continue;
        }
        if f.block_family == Some(family) {
            if let Some(reason) = &f.block_reason {
                seen.insert(reason.clone());
            }
        }
    }
    seen.into_iter().collect()
}

/// Maps dominant block family + reason strings to an operator-facing action.
fn derive_operator_action(family: BlockFamily, reasons: &[String]) -> Option<String> {
    match family {
        BlockFamily::Authorization => Some(auth_action(reasons)),
        BlockFamily::Method => Some(
            "Retry with a different HTTP method — the server rejects this method before resource \
             lookup"
                .to_owned(),
        ),
        _ => None,
    }
}

fn auth_action(reasons: &[String]) -> String {
    // Match against the exact strings produced by `PreconditionBlock::as_str()`.
    let joined = reasons.join(" ");
    if joined.contains("no credential provided") {
        return r#"Retry with --header "Authorization: Bearer <token>""#.to_owned();
    }
    if joined.contains("credential rejected") {
        return "Retry with a valid credential — current token is invalid/expired".to_owned();
    }
    if joined.contains("lacks required scope") {
        return "Retry with a credential whose scope includes the required permission".to_owned();
    }
    if joined.contains("proxy auth required") {
        return "Configure proxy authentication".to_owned();
    }
    if joined.contains("network/captive-portal") {
        return "Authenticate to network/captive portal".to_owned();
    }
    if joined.contains("login-redirect fired") {
        return "Establish a session via the login flow before scanning".to_owned();
    }
    // Default — the most common unauthenticated case.
    r#"Retry with --header "Authorization: Bearer <token>""#.to_owned()
}

#[cfg(test)]
#[path = "observability_compute_tests.rs"]
mod tests;