parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Precondition gate logic for evidence modifiers.
//!
//! Detects when a Contradictory outcome is "phantom" — produced by a technique whose
//! mutation never reached the layer where it would matter. Auth gates, method-level
//! rejection, and parser failures are the most common phantom sources.
//!
//! The auth check uses a two-stage classifier (see [`super::auth_classifier`]) that suppresses
//! only non-differential same-layer auth blocks; status/body/header/location/challenge
//! differentials are preserved as evidence.

use parlov_core::{Applicability, BlockFamily, DifferentialSet, ProbeExchange, Technique};

use super::auth_equivalence::auth_gate_decision;
use super::auth_types::{AuthGateDecision, CredentialBlockKind};

/// Layer at which an auth block fired. Determines the operator-facing reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthBlockLayer {
    /// Origin server (401 / 403 with explicit auth signal).
    Origin,
    /// Proxy in front of origin (407).
    Proxy,
    /// Network gatekeeper / captive portal (511).
    Network,
    /// Heuristic 3xx-to-login redirect.
    LoginRedirect,
}

/// Structured reason a Contradictory outcome was downgraded.
///
/// Carried in `Inapplicable(reason)` to give operators an actionable diagnosis instead of an
/// opaque "no signal."
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreconditionBlock {
    /// Auth gate fired before the technique reached the oracle layer. Carries the credential
    /// state (why the credential failed) and the layer at which the gate fired.
    AuthGateBeforeTechnique {
        /// Why the credential failed or was not provided.
        credential_state: CredentialBlockKind,
        /// Layer at which the auth block fired.
        layer: AuthBlockLayer,
    },
    /// Both responses were 405 — method-level rejection before resource lookup.
    MethodGateBeforeResource,
    /// Both responses were 4xx parser/validator rejection and the technique is downstream of parse.
    BlockedByParser,
    /// Required applicability marker absent (e.g. `ETag` missing for `If-None-Match`).
    ApplicabilityMarkerMissing,
    /// Differential observed on a surface this technique does not test (e.g. body/headers differ
    /// on a Status-surface technique).
    SurfaceMismatch,
    /// Route-mutating technique broke baseline routing — canonical 2xx with mutated baseline
    /// non-2xx, or canonical 301/308 (server canonicalized away from the mutated path).
    MutationDestroyedControl,
}

impl PreconditionBlock {
    /// Coarse block family used for observability classification.
    #[must_use]
    pub fn block_family(self) -> BlockFamily {
        match self {
            Self::AuthGateBeforeTechnique { .. } => BlockFamily::Authorization,
            Self::MethodGateBeforeResource => BlockFamily::Method,
            Self::BlockedByParser => BlockFamily::Parser,
            Self::ApplicabilityMarkerMissing | Self::MutationDestroyedControl => {
                BlockFamily::TechniqueLocal
            }
            Self::SurfaceMismatch => BlockFamily::Surface,
        }
    }

    /// Operator-facing reason string surfaced in the `Inapplicable` outcome.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::AuthGateBeforeTechnique {
                credential_state,
                layer,
            } => auth_block_reason(credential_state, layer),
            Self::MethodGateBeforeResource => "method-level rejection before resource lookup",
            Self::BlockedByParser => "parser/validator rejection before technique evaluated",
            Self::ApplicabilityMarkerMissing => "technique applicability marker not observed",
            Self::SurfaceMismatch => {
                "differential observed on a surface this technique does not test"
            }
            Self::MutationDestroyedControl => {
                "mutation broke baseline route — control reference destroyed"
            }
        }
    }
}

/// Maps an `(credential_state, layer)` pair to the operator-facing reason string.
#[must_use]
fn auth_block_reason(credential_state: CredentialBlockKind, layer: AuthBlockLayer) -> &'static str {
    match (credential_state, layer) {
        (CredentialBlockKind::NoCredential, AuthBlockLayer::Origin) => {
            "auth gate fired before technique (no credential provided)"
        }
        (CredentialBlockKind::CredentialRejected, AuthBlockLayer::Origin) => {
            "auth gate fired before technique (credential rejected — token invalid/expired)"
        }
        (CredentialBlockKind::InsufficientScope, AuthBlockLayer::Origin) => {
            "auth gate fired before technique (credential lacks required scope)"
        }
        (CredentialBlockKind::UnknownAuthFailure, AuthBlockLayer::Origin) => {
            "auth gate fired before technique (specific failure not identified)"
        }
        (CredentialBlockKind::NotApplicable, AuthBlockLayer::Origin) => {
            "auth gate fired before technique reached oracle layer"
        }
        (_, AuthBlockLayer::Proxy) => "proxy auth required before technique reached origin",
        (_, AuthBlockLayer::Network) => "network/captive-portal auth required",
        (_, AuthBlockLayer::LoginRedirect) => "login-redirect fired before technique",
    }
}

/// Decision returned by [`precondition_confidence`]. Carries either a confidence value or a
/// structured block reason.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PreconditionDecision {
    /// Request reached the relevant layer. Confidence in `[0.0, 1.0]`.
    Reached(f64),
    /// Hard block — Contradictory must be downgraded to Inapplicable.
    Blocked(PreconditionBlock),
}

impl PreconditionDecision {
    /// Confidence value for the modifier. `Blocked` → 0.0; `Reached(c)` → `c`.
    #[must_use]
    pub fn confidence(self) -> f64 {
        match self {
            Self::Reached(c) => c,
            Self::Blocked(_) => 0.0,
        }
    }

    /// Block reason if decision is blocked, else `None`.
    #[must_use]
    pub fn block_reason(self) -> Option<PreconditionBlock> {
        match self {
            Self::Blocked(reason) => Some(reason),
            Self::Reached(_) => None,
        }
    }
}

/// True when both responses are 405 Method Not Allowed.
fn same_method_gate(differential: &DifferentialSet) -> bool {
    let Some((b, p)) = first_pair(differential) else {
        return false;
    };
    b.response.status.as_u16() == 405 && p.response.status.as_u16() == 405
}

/// True when both responses are 400 or 422 with the same status code.
///
/// Parser failures are oracle-relevant for some techniques (`uniqueness`, `state-transition`,
/// `content-type`, `empty-body`, `dependency-delete`) and phantom for others (cache
/// validators, content negotiation, etc.) — the caller uses `Technique::parser_relevant`
/// to decide.
fn same_parser_failure(differential: &DifferentialSet) -> bool {
    let Some((b, p)) = first_pair(differential) else {
        return false;
    };
    let bs = b.response.status.as_u16();
    let ps = p.response.status.as_u16();
    matches!(bs, 400 | 422) && bs == ps
}

/// First baseline+probe exchange pair, or `None` if either side is empty.
fn first_pair(differential: &DifferentialSet) -> Option<(&ProbeExchange, &ProbeExchange)> {
    let b = differential.baseline.first()?;
    let p = differential.probe.first()?;
    Some((b, p))
}

/// Computes the precondition confidence for a technique against a probe pair.
///
/// Pipeline:
/// 1. Auth-gate decision via the two-stage classifier — gates only on equivalent same-layer
///    auth blocks; preserves auth-related differentials as evidence.
/// 2. Method-gate check — same 405 unless technique is method-relevant.
/// 3. Parser-failure check — same 400/422 unless technique is parser-relevant.
/// 4. Applicability marker check — calls `technique.applicability` to grade the response pair.
///
/// Returns `Reached(c)` with the applicability confidence, or `Blocked(reason)` when any gate
/// fires.
#[must_use]
pub fn precondition_confidence(
    technique: &Technique,
    differential: &DifferentialSet,
) -> PreconditionDecision {
    match auth_gate_decision(differential) {
        AuthGateDecision::Gate(reason) => return PreconditionDecision::Blocked(reason),
        AuthGateDecision::DoNotGate => return PreconditionDecision::Reached(1.0),
        AuthGateDecision::NoAuthInvolvement => {}
    }
    if same_method_gate(differential) && !technique.method_relevant {
        return PreconditionDecision::Blocked(PreconditionBlock::MethodGateBeforeResource);
    }
    if same_parser_failure(differential) && !technique.parser_relevant {
        return PreconditionDecision::Blocked(PreconditionBlock::BlockedByParser);
    }
    let Some((b, p)) = first_pair(differential) else {
        return PreconditionDecision::Reached(1.0);
    };
    let app = (technique.applicability)(&b.response, &p.response);
    if app == Applicability::Missing {
        return PreconditionDecision::Blocked(PreconditionBlock::ApplicabilityMarkerMissing);
    }
    PreconditionDecision::Reached(app.confidence())
}

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