Skip to main content

parlov_analysis/aggregation/
precondition.rs

1//! Precondition gate logic for evidence modifiers.
2//!
3//! Detects when a Contradictory outcome is "phantom" — produced by a technique whose
4//! mutation never reached the layer where it would matter. Auth gates, method-level
5//! rejection, and parser failures are the most common phantom sources.
6//!
7//! The auth check uses a two-stage classifier (see [`super::auth_classifier`]) that suppresses
8//! only non-differential same-layer auth blocks; status/body/header/location/challenge
9//! differentials are preserved as evidence.
10
11use parlov_core::{Applicability, BlockFamily, DifferentialSet, ProbeExchange, Technique};
12
13use super::auth_equivalence::auth_gate_decision;
14use super::auth_types::{AuthGateDecision, CredentialBlockKind};
15
16/// Layer at which an auth block fired. Determines the operator-facing reason.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AuthBlockLayer {
19    /// Origin server (401 / 403 with explicit auth signal).
20    Origin,
21    /// Proxy in front of origin (407).
22    Proxy,
23    /// Network gatekeeper / captive portal (511).
24    Network,
25    /// Heuristic 3xx-to-login redirect.
26    LoginRedirect,
27}
28
29/// Structured reason a Contradictory outcome was downgraded.
30///
31/// Carried in `Inapplicable(reason)` to give operators an actionable diagnosis instead of an
32/// opaque "no signal."
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PreconditionBlock {
35    /// Auth gate fired before the technique reached the oracle layer. Carries the credential
36    /// state (why the credential failed) and the layer at which the gate fired.
37    AuthGateBeforeTechnique {
38        /// Why the credential failed or was not provided.
39        credential_state: CredentialBlockKind,
40        /// Layer at which the auth block fired.
41        layer: AuthBlockLayer,
42    },
43    /// Both responses were 405 — method-level rejection before resource lookup.
44    MethodGateBeforeResource,
45    /// Both responses were 4xx parser/validator rejection and the technique is downstream of parse.
46    BlockedByParser,
47    /// Required applicability marker absent (e.g. `ETag` missing for `If-None-Match`).
48    ApplicabilityMarkerMissing,
49    /// Differential observed on a surface this technique does not test (e.g. body/headers differ
50    /// on a Status-surface technique).
51    SurfaceMismatch,
52    /// Route-mutating technique broke baseline routing — canonical 2xx with mutated baseline
53    /// non-2xx, or canonical 301/308 (server canonicalized away from the mutated path).
54    MutationDestroyedControl,
55}
56
57impl PreconditionBlock {
58    /// Coarse block family used for observability classification.
59    #[must_use]
60    pub fn block_family(self) -> BlockFamily {
61        match self {
62            Self::AuthGateBeforeTechnique { .. } => BlockFamily::Authorization,
63            Self::MethodGateBeforeResource => BlockFamily::Method,
64            Self::BlockedByParser => BlockFamily::Parser,
65            Self::ApplicabilityMarkerMissing | Self::MutationDestroyedControl => {
66                BlockFamily::TechniqueLocal
67            }
68            Self::SurfaceMismatch => BlockFamily::Surface,
69        }
70    }
71
72    /// Operator-facing reason string surfaced in the `Inapplicable` outcome.
73    #[must_use]
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Self::AuthGateBeforeTechnique {
77                credential_state,
78                layer,
79            } => auth_block_reason(credential_state, layer),
80            Self::MethodGateBeforeResource => "method-level rejection before resource lookup",
81            Self::BlockedByParser => "parser/validator rejection before technique evaluated",
82            Self::ApplicabilityMarkerMissing => "technique applicability marker not observed",
83            Self::SurfaceMismatch => {
84                "differential observed on a surface this technique does not test"
85            }
86            Self::MutationDestroyedControl => {
87                "mutation broke baseline route — control reference destroyed"
88            }
89        }
90    }
91}
92
93/// Maps an `(credential_state, layer)` pair to the operator-facing reason string.
94#[must_use]
95fn auth_block_reason(credential_state: CredentialBlockKind, layer: AuthBlockLayer) -> &'static str {
96    match (credential_state, layer) {
97        (CredentialBlockKind::NoCredential, AuthBlockLayer::Origin) => {
98            "auth gate fired before technique (no credential provided)"
99        }
100        (CredentialBlockKind::CredentialRejected, AuthBlockLayer::Origin) => {
101            "auth gate fired before technique (credential rejected — token invalid/expired)"
102        }
103        (CredentialBlockKind::InsufficientScope, AuthBlockLayer::Origin) => {
104            "auth gate fired before technique (credential lacks required scope)"
105        }
106        (CredentialBlockKind::UnknownAuthFailure, AuthBlockLayer::Origin) => {
107            "auth gate fired before technique (specific failure not identified)"
108        }
109        (CredentialBlockKind::NotApplicable, AuthBlockLayer::Origin) => {
110            "auth gate fired before technique reached oracle layer"
111        }
112        (_, AuthBlockLayer::Proxy) => "proxy auth required before technique reached origin",
113        (_, AuthBlockLayer::Network) => "network/captive-portal auth required",
114        (_, AuthBlockLayer::LoginRedirect) => "login-redirect fired before technique",
115    }
116}
117
118/// Decision returned by [`precondition_confidence`]. Carries either a confidence value or a
119/// structured block reason.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum PreconditionDecision {
122    /// Request reached the relevant layer. Confidence in `[0.0, 1.0]`.
123    Reached(f64),
124    /// Hard block — Contradictory must be downgraded to Inapplicable.
125    Blocked(PreconditionBlock),
126}
127
128impl PreconditionDecision {
129    /// Confidence value for the modifier. `Blocked` → 0.0; `Reached(c)` → `c`.
130    #[must_use]
131    pub fn confidence(self) -> f64 {
132        match self {
133            Self::Reached(c) => c,
134            Self::Blocked(_) => 0.0,
135        }
136    }
137
138    /// Block reason if decision is blocked, else `None`.
139    #[must_use]
140    pub fn block_reason(self) -> Option<PreconditionBlock> {
141        match self {
142            Self::Blocked(reason) => Some(reason),
143            Self::Reached(_) => None,
144        }
145    }
146}
147
148/// True when both responses are 405 Method Not Allowed.
149fn same_method_gate(differential: &DifferentialSet) -> bool {
150    let Some((b, p)) = first_pair(differential) else {
151        return false;
152    };
153    b.response.status.as_u16() == 405 && p.response.status.as_u16() == 405
154}
155
156/// True when both responses are 400 or 422 with the same status code.
157///
158/// Parser failures are oracle-relevant for some techniques (`uniqueness`, `state-transition`,
159/// `content-type`, `empty-body`, `dependency-delete`) and phantom for others (cache
160/// validators, content negotiation, etc.) — the caller uses `Technique::parser_relevant`
161/// to decide.
162fn same_parser_failure(differential: &DifferentialSet) -> bool {
163    let Some((b, p)) = first_pair(differential) else {
164        return false;
165    };
166    let bs = b.response.status.as_u16();
167    let ps = p.response.status.as_u16();
168    matches!(bs, 400 | 422) && bs == ps
169}
170
171/// First baseline+probe exchange pair, or `None` if either side is empty.
172fn first_pair(differential: &DifferentialSet) -> Option<(&ProbeExchange, &ProbeExchange)> {
173    let b = differential.baseline.first()?;
174    let p = differential.probe.first()?;
175    Some((b, p))
176}
177
178/// Computes the precondition confidence for a technique against a probe pair.
179///
180/// Pipeline:
181/// 1. Auth-gate decision via the two-stage classifier — gates only on equivalent same-layer
182///    auth blocks; preserves auth-related differentials as evidence.
183/// 2. Method-gate check — same 405 unless technique is method-relevant.
184/// 3. Parser-failure check — same 400/422 unless technique is parser-relevant.
185/// 4. Applicability marker check — calls `technique.applicability` to grade the response pair.
186///
187/// Returns `Reached(c)` with the applicability confidence, or `Blocked(reason)` when any gate
188/// fires.
189#[must_use]
190pub fn precondition_confidence(
191    technique: &Technique,
192    differential: &DifferentialSet,
193) -> PreconditionDecision {
194    match auth_gate_decision(differential) {
195        AuthGateDecision::Gate(reason) => return PreconditionDecision::Blocked(reason),
196        AuthGateDecision::DoNotGate => return PreconditionDecision::Reached(1.0),
197        AuthGateDecision::NoAuthInvolvement => {}
198    }
199    if same_method_gate(differential) && !technique.method_relevant {
200        return PreconditionDecision::Blocked(PreconditionBlock::MethodGateBeforeResource);
201    }
202    if same_parser_failure(differential) && !technique.parser_relevant {
203        return PreconditionDecision::Blocked(PreconditionBlock::BlockedByParser);
204    }
205    let Some((b, p)) = first_pair(differential) else {
206        return PreconditionDecision::Reached(1.0);
207    };
208    let app = (technique.applicability)(&b.response, &p.response);
209    if app == Applicability::Missing {
210        return PreconditionDecision::Blocked(PreconditionBlock::ApplicabilityMarkerMissing);
211    }
212    PreconditionDecision::Reached(app.confidence())
213}
214
215#[cfg(test)]
216#[path = "precondition_tests.rs"]
217mod tests;