Skip to main content

termivar_scanner/
oast.rs

1//! Provider-neutral OAST correlation state.
2//!
3//! The host owns entropy, transport, scheduling, and projection into findings.
4//! This module only binds opaque identities, accounts for bounded polls, checks
5//! injected monotonic time, and commits raw-free callback classifications.
6
7use crate::verification::VerificationCase;
8use sha2::{Digest, Sha256};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    error::Error,
12    fmt,
13};
14use zeroize::Zeroize;
15
16const TOKEN_BYTES: usize = 32;
17const EVENT_KEY_BYTES: usize = 32;
18const MAX_ASSESSMENT_ID_BYTES: usize = 256;
19const MAX_VERIFICATION_BINDING_COMPONENT_BYTES: usize = 256;
20const HARD_MAX_REGISTRATIONS: u16 = 4_096;
21const HARD_MAX_POLLS: u16 = 64;
22const HARD_MAX_EVENTS_PER_POLL: u16 = 64;
23const HARD_MAX_UNIQUE_EVENTS: u16 = 1_024;
24const HARD_MAX_LIFETIME_MILLIS: u64 = 86_400_000;
25
26const TOKEN_REUSE_DOMAIN: &[u8] = b"security.oast-correlation.token-reuse.v1\0";
27const BINDING_ID_DOMAIN: &[u8] = b"security.oast-correlation.binding.v1\0";
28const CORRELATION_ID_DOMAIN: &[u8] = b"security.oast-correlation.id.v1\0";
29
30/// Validation and state-transition failures for OAST correlation.
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum OastError {
34    /// The assessment identity was empty.
35    EmptyAssessmentId,
36    /// The assessment identity exceeded its bound.
37    AssessmentIdTooLong {
38        /// Supplied byte length.
39        actual: usize,
40        /// Maximum byte length.
41        maximum: usize,
42    },
43    /// The assessment identity contained a disallowed byte.
44    InvalidAssessmentId,
45    /// An opaque 32-byte value used the reserved all-zero sentinel.
46    ZeroOpaqueId,
47    /// The lifetime was zero.
48    ZeroLifetime,
49    /// The lifetime exceeded the hard foundation bound.
50    LifetimeTooLong {
51        /// Requested milliseconds.
52        actual: u64,
53        /// Maximum milliseconds.
54        maximum: u64,
55    },
56    /// The poll budget was zero.
57    ZeroPollBudget,
58    /// The poll budget exceeded the hard foundation bound.
59    PollBudgetTooLarge {
60        /// Requested polls.
61        actual: u16,
62        /// Maximum polls.
63        maximum: u16,
64    },
65    /// At least one authority limit was zero.
66    ZeroAuthorityLimit,
67    /// One authority limit exceeded its hard foundation bound.
68    AuthorityLimitTooLarge,
69    /// No callback protocol was allowed.
70    EmptyProtocolSet,
71    /// The authority epoch reached its registration limit.
72    RegistrationCapacityExhausted,
73    /// The token had already been consumed in this authority epoch.
74    TokenAlreadyRegistered,
75    /// A verification-case identity component exceeded the OAST binding bound.
76    VerificationBindingTooLarge,
77    /// The requested lifetime exceeded the authority grant.
78    LifetimeGrantExceeded {
79        /// Requested milliseconds.
80        requested: u64,
81        /// Granted maximum milliseconds.
82        maximum: u64,
83    },
84    /// The requested poll budget exceeded the authority grant.
85    PollGrantExceeded {
86        /// Requested polls.
87        requested: u16,
88        /// Granted maximum polls.
89        maximum: u16,
90    },
91    /// Adding lifetime to the issued time overflowed.
92    ExpiryOverflow,
93    /// Assessment or verification-case identity did not match exactly.
94    BindingMismatch,
95    /// A callback was routed with another correlation identity.
96    CorrelationMismatch,
97    /// A callback used a protocol outside the registration grant.
98    ProtocolNotAllowed {
99        /// Rejected protocol.
100        protocol: OastEventProtocol,
101    },
102    /// The injected monotonic time moved backwards.
103    ClockRegressed {
104        /// Last accepted time.
105        previous: OastMonotonicTime,
106        /// Regressed time.
107        current: OastMonotonicTime,
108    },
109    /// The correlation is expired.
110    Expired {
111        /// Exclusive expiry boundary.
112        expires_at: OastMonotonicTime,
113    },
114    /// The correlation was cancelled.
115    Cancelled,
116    /// Every poll in the grant was spent.
117    PollBudgetExhausted,
118    /// A staged poll exceeded its event bound.
119    PollEventLimitExceeded {
120        /// Maximum events in one poll.
121        maximum: u16,
122    },
123    /// Atomic completion would exceed the registration unique-event bound.
124    UniqueEventLimitExceeded {
125        /// Maximum unique event keys.
126        maximum: u16,
127    },
128    /// One event key was observed under two distinct protocol families.
129    EventKeyProtocolConflict,
130}
131
132impl fmt::Display for OastError {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::EmptyAssessmentId => formatter.write_str("OAST assessment id is empty"),
136            Self::AssessmentIdTooLong { actual, maximum } => {
137                write!(
138                    formatter,
139                    "OAST assessment id length {actual} exceeds {maximum}"
140                )
141            },
142            Self::InvalidAssessmentId => formatter.write_str("OAST assessment id is invalid"),
143            Self::ZeroOpaqueId => formatter.write_str("OAST opaque id must not be all zero"),
144            Self::ZeroLifetime => formatter.write_str("OAST lifetime must be non-zero"),
145            Self::LifetimeTooLong { actual, maximum } => {
146                write!(formatter, "OAST lifetime {actual}ms exceeds {maximum}ms")
147            },
148            Self::ZeroPollBudget => formatter.write_str("OAST poll budget must be non-zero"),
149            Self::PollBudgetTooLarge { actual, maximum } => {
150                write!(formatter, "OAST poll budget {actual} exceeds {maximum}")
151            },
152            Self::ZeroAuthorityLimit => {
153                formatter.write_str("OAST authority limits must be non-zero")
154            },
155            Self::AuthorityLimitTooLarge => {
156                formatter.write_str("OAST authority limit exceeds a hard foundation bound")
157            },
158            Self::EmptyProtocolSet => formatter.write_str("OAST protocol set must not be empty"),
159            Self::RegistrationCapacityExhausted => {
160                formatter.write_str("OAST registration capacity is exhausted")
161            },
162            Self::TokenAlreadyRegistered => {
163                formatter.write_str("OAST token was already registered in this authority epoch")
164            },
165            Self::VerificationBindingTooLarge => {
166                formatter.write_str("OAST verification binding exceeds its component bound")
167            },
168            Self::LifetimeGrantExceeded { requested, maximum } => {
169                write!(
170                    formatter,
171                    "OAST lifetime {requested}ms exceeds grant {maximum}ms"
172                )
173            },
174            Self::PollGrantExceeded { requested, maximum } => {
175                write!(
176                    formatter,
177                    "OAST poll count {requested} exceeds grant {maximum}"
178                )
179            },
180            Self::ExpiryOverflow => formatter.write_str("OAST expiry overflowed monotonic time"),
181            Self::BindingMismatch => formatter.write_str("OAST binding does not match"),
182            Self::CorrelationMismatch => formatter.write_str("OAST correlation does not match"),
183            Self::ProtocolNotAllowed { protocol } => {
184                write!(formatter, "OAST protocol {protocol:?} is not allowed")
185            },
186            Self::ClockRegressed { previous, current } => write!(
187                formatter,
188                "OAST time regressed from {}ms to {}ms",
189                previous.as_millis(),
190                current.as_millis()
191            ),
192            Self::Expired { expires_at } => {
193                write!(
194                    formatter,
195                    "OAST correlation expired at {}ms",
196                    expires_at.as_millis()
197                )
198            },
199            Self::Cancelled => formatter.write_str("OAST correlation is cancelled"),
200            Self::PollBudgetExhausted => formatter.write_str("OAST poll budget is exhausted"),
201            Self::PollEventLimitExceeded { maximum } => {
202                write!(formatter, "OAST poll exceeds its {maximum}-event bound")
203            },
204            Self::UniqueEventLimitExceeded { maximum } => {
205                write!(
206                    formatter,
207                    "OAST registration exceeds its {maximum}-event bound"
208                )
209            },
210            Self::EventKeyProtocolConflict => {
211                formatter.write_str("OAST event key has conflicting protocol families")
212            },
213        }
214    }
215}
216
217impl Error for OastError {}
218
219/// Exact host-owned assessment identity.
220#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
221pub struct OastAssessmentId(String);
222
223impl OastAssessmentId {
224    /// Validates an exact identity without trimming or normalization.
225    pub fn new(value: impl AsRef<str>) -> Result<Self, OastError> {
226        let value = value.as_ref();
227        if value.is_empty() {
228            return Err(OastError::EmptyAssessmentId);
229        }
230        if value.len() > MAX_ASSESSMENT_ID_BYTES {
231            return Err(OastError::AssessmentIdTooLong {
232                actual: value.len(),
233                maximum: MAX_ASSESSMENT_ID_BYTES,
234            });
235        }
236        if !value
237            .bytes()
238            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':'))
239        {
240            return Err(OastError::InvalidAssessmentId);
241        }
242        Ok(Self(value.to_owned()))
243    }
244
245    /// Returns the validated identity.
246    pub fn as_str(&self) -> &str {
247        &self.0
248    }
249}
250
251impl fmt::Debug for OastAssessmentId {
252    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253        formatter.write_str("OastAssessmentId(<redacted>)")
254    }
255}
256
257/// Host-minted single-use correlation secret.
258///
259/// The host must use a cryptographically secure source and must not reuse the
260/// bytes. The wrapper is move-only, exposes no raw getter, and is consumed by
261/// registration.
262pub struct OastCorrelationToken {
263    secret_bytes: [u8; 32],
264}
265
266impl OastCorrelationToken {
267    /// Wraps 32 unpredictable host-minted bytes.
268    pub fn new(secret_bytes: [u8; TOKEN_BYTES]) -> Result<Self, OastError> {
269        reject_zero(&secret_bytes)?;
270        Ok(Self { secret_bytes })
271    }
272
273    fn erase(&mut self) {
274        self.secret_bytes.zeroize();
275    }
276}
277
278impl fmt::Debug for OastCorrelationToken {
279    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
280        formatter.write_str("OastCorrelationToken(<redacted>)")
281    }
282}
283
284impl Drop for OastCorrelationToken {
285    fn drop(&mut self) {
286        self.erase();
287    }
288}
289
290/// Opaque identity for one authority lifetime.
291#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
292pub struct OastAuthorityEpoch([u8; 32]);
293
294impl OastAuthorityEpoch {
295    /// Wraps a host-minted, non-zero epoch identity.
296    pub fn new(bytes: [u8; 32]) -> Result<Self, OastError> {
297        reject_zero(&bytes)?;
298        Ok(Self(bytes))
299    }
300
301    /// Consumes the epoch only at the sealed native-provider configuration boundary.
302    #[cfg(feature = "ssrf-oast-review")]
303    pub(crate) const fn into_bytes(self) -> [u8; 32] {
304        self.0
305    }
306}
307
308impl fmt::Debug for OastAuthorityEpoch {
309    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310        formatter.write_str("OastAuthorityEpoch(<redacted>)")
311    }
312}
313
314/// Opaque digest of every semantic registration grant.
315#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
316pub struct OastBindingId([u8; 32]);
317
318impl fmt::Debug for OastBindingId {
319    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320        formatter.write_str("OastBindingId(<redacted>)")
321    }
322}
323
324/// Opaque token-keyed identity for one exact binding.
325#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
326pub struct OastCorrelationId([u8; 32]);
327
328impl fmt::Debug for OastCorrelationId {
329    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330        formatter.write_str("OastCorrelationId(<redacted>)")
331    }
332}
333
334/// Opaque provider-neutral identity for one callback event.
335#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
336pub struct OastEventKey([u8; 32]);
337
338impl OastEventKey {
339    /// Wraps a non-zero 32-byte event identity prepared by the host.
340    pub fn new(bytes: [u8; EVENT_KEY_BYTES]) -> Result<Self, OastError> {
341        reject_zero(&bytes)?;
342        Ok(Self(bytes))
343    }
344
345    /// Borrows the already-reduced opaque identity for in-crate differential review.
346    #[cfg(feature = "ssrf-oast-review")]
347    pub(crate) const fn as_bytes(&self) -> &[u8; EVENT_KEY_BYTES] {
348        &self.0
349    }
350}
351
352impl fmt::Debug for OastEventKey {
353    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354        formatter.write_str("OastEventKey(<redacted>)")
355    }
356}
357
358/// Host-supplied monotonic milliseconds from an arbitrary origin.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
360pub struct OastMonotonicTime(u64);
361
362impl OastMonotonicTime {
363    /// Creates a monotonic time value.
364    pub const fn from_millis(milliseconds: u64) -> Self {
365        Self(milliseconds)
366    }
367
368    /// Returns the host clock value.
369    pub const fn as_millis(self) -> u64 {
370        self.0
371    }
372}
373
374/// Validated correlation lifetime.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub struct OastLifetime(u64);
377
378impl OastLifetime {
379    /// Creates a non-zero, hard-bounded lifetime.
380    pub fn from_millis(milliseconds: u64) -> Result<Self, OastError> {
381        if milliseconds == 0 {
382            return Err(OastError::ZeroLifetime);
383        }
384        if milliseconds > HARD_MAX_LIFETIME_MILLIS {
385            return Err(OastError::LifetimeTooLong {
386                actual: milliseconds,
387                maximum: HARD_MAX_LIFETIME_MILLIS,
388            });
389        }
390        Ok(Self(milliseconds))
391    }
392
393    /// Returns the lifetime in milliseconds.
394    pub const fn as_millis(self) -> u64 {
395        self.0
396    }
397}
398
399/// Validated poll allowance for one registration.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub struct OastPollBudget(u16);
402
403impl OastPollBudget {
404    /// Creates a non-zero, hard-bounded poll allowance.
405    pub fn new(polls: u16) -> Result<Self, OastError> {
406        if polls == 0 {
407            return Err(OastError::ZeroPollBudget);
408        }
409        if polls > HARD_MAX_POLLS {
410            return Err(OastError::PollBudgetTooLarge {
411                actual: polls,
412                maximum: HARD_MAX_POLLS,
413            });
414        }
415        Ok(Self(polls))
416    }
417
418    /// Returns the poll allowance.
419    pub const fn polls(self) -> u16 {
420        self.0
421    }
422}
423
424/// Explicit bounds owned by one authority epoch.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub struct OastAuthorityLimits {
427    max_registrations: u16,
428    max_polls_per_registration: u16,
429    max_events_per_poll: u16,
430    max_unique_events_per_registration: u16,
431    max_lifetime_millis: u64,
432}
433
434impl OastAuthorityLimits {
435    /// Validates all authority bounds.
436    pub fn new(
437        max_registrations: u16,
438        max_polls_per_registration: u16,
439        max_events_per_poll: u16,
440        max_unique_events_per_registration: u16,
441        max_lifetime_millis: u64,
442    ) -> Result<Self, OastError> {
443        if max_registrations == 0
444            || max_polls_per_registration == 0
445            || max_events_per_poll == 0
446            || max_unique_events_per_registration == 0
447            || max_lifetime_millis == 0
448        {
449            return Err(OastError::ZeroAuthorityLimit);
450        }
451        if max_registrations > HARD_MAX_REGISTRATIONS
452            || max_polls_per_registration > HARD_MAX_POLLS
453            || max_events_per_poll > HARD_MAX_EVENTS_PER_POLL
454            || max_unique_events_per_registration > HARD_MAX_UNIQUE_EVENTS
455            || max_lifetime_millis > HARD_MAX_LIFETIME_MILLIS
456        {
457            return Err(OastError::AuthorityLimitTooLarge);
458        }
459        Ok(Self {
460            max_registrations,
461            max_polls_per_registration,
462            max_events_per_poll,
463            max_unique_events_per_registration,
464            max_lifetime_millis,
465        })
466    }
467
468    /// Returns the registration bound.
469    pub const fn max_registrations(self) -> u16 {
470        self.max_registrations
471    }
472
473    /// Returns the per-registration poll bound.
474    pub const fn max_polls_per_registration(self) -> u16 {
475        self.max_polls_per_registration
476    }
477
478    /// Returns the per-poll event bound.
479    pub const fn max_events_per_poll(self) -> u16 {
480        self.max_events_per_poll
481    }
482
483    /// Returns the per-registration unique-event bound.
484    pub const fn max_unique_events_per_registration(self) -> u16 {
485        self.max_unique_events_per_registration
486    }
487
488    /// Returns the lifetime bound.
489    pub const fn max_lifetime_millis(self) -> u64 {
490        self.max_lifetime_millis
491    }
492}
493
494/// Closed non-empty set of callback protocols granted to a registration.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub struct OastProtocolSet(u8);
497
498impl OastProtocolSet {
499    const DNS: u8 = 1;
500    const HTTP: u8 = 2;
501
502    /// Creates a non-empty protocol grant.
503    pub fn new(allow_dns: bool, allow_http: bool) -> Result<Self, OastError> {
504        let bits = (u8::from(allow_dns) * Self::DNS) | (u8::from(allow_http) * Self::HTTP);
505        if bits == 0 {
506            return Err(OastError::EmptyProtocolSet);
507        }
508        Ok(Self(bits))
509    }
510
511    /// Returns whether this grant permits a protocol.
512    pub const fn allows(self, protocol: OastEventProtocol) -> bool {
513        let bit = match protocol {
514            OastEventProtocol::Dns => Self::DNS,
515            OastEventProtocol::Http => Self::HTTP,
516        };
517        self.0 & bit != 0
518    }
519
520    /// Returns whether DNS is granted.
521    pub const fn allows_dns(self) -> bool {
522        self.0 & Self::DNS != 0
523    }
524
525    /// Returns whether HTTP is granted.
526    pub const fn allows_http(self) -> bool {
527        self.0 & Self::HTTP != 0
528    }
529}
530
531/// Transport classification for a raw-free DNS event.
532#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
533pub enum OastDnsTransport {
534    /// Datagram transport.
535    Udp,
536    /// Stream transport.
537    Tcp,
538}
539
540/// Record classification for a raw-free DNS event.
541#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
542#[non_exhaustive]
543pub enum OastDnsRecordType {
544    /// IPv4 address.
545    A,
546    /// IPv6 address.
547    Aaaa,
548    /// Canonical name.
549    Cname,
550    /// Mail exchange.
551    Mx,
552    /// Name server.
553    Ns,
554    /// Reverse pointer.
555    Ptr,
556    /// Service locator.
557    Srv,
558    /// Text.
559    Txt,
560    /// Another record type, without its raw numeric value.
561    Other,
562}
563
564/// Typed DNS callback without names, addresses, or payload bytes.
565#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
566pub struct OastDnsEvent {
567    transport: OastDnsTransport,
568    record_type: OastDnsRecordType,
569}
570
571impl OastDnsEvent {
572    /// Creates a raw-free DNS classification.
573    pub const fn new(transport: OastDnsTransport, record_type: OastDnsRecordType) -> Self {
574        Self {
575            transport,
576            record_type,
577        }
578    }
579
580    /// Returns the transport.
581    pub const fn transport(self) -> OastDnsTransport {
582        self.transport
583    }
584
585    /// Returns the record type.
586    pub const fn record_type(self) -> OastDnsRecordType {
587        self.record_type
588    }
589}
590
591/// Scheme classification for a raw-free HTTP event.
592#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
593pub enum OastHttpScheme {
594    /// Cleartext HTTP.
595    Http,
596    /// TLS-protected HTTP.
597    Https,
598}
599
600/// Method classification for a raw-free HTTP event.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
602#[non_exhaustive]
603pub enum OastHttpMethod {
604    /// The provider retained an HTTP interaction without retaining its method.
605    ///
606    /// This is deliberately distinct from guessing `GET`: a raw-free provider
607    /// may prove only that an HTTP callback occurred.
608    Unknown,
609    /// GET.
610    Get,
611    /// HEAD.
612    Head,
613    /// POST.
614    Post,
615    /// PUT.
616    Put,
617    /// PATCH.
618    Patch,
619    /// DELETE.
620    Delete,
621    /// OPTIONS.
622    Options,
623    /// TRACE.
624    Trace,
625    /// CONNECT.
626    Connect,
627    /// Another method, without its raw token.
628    Other,
629}
630
631/// Typed HTTP callback without paths, headers, addresses, or body bytes.
632#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
633pub struct OastHttpEvent {
634    scheme: OastHttpScheme,
635    method: OastHttpMethod,
636    body_present: bool,
637}
638
639impl OastHttpEvent {
640    /// Creates a raw-free HTTP classification.
641    pub const fn new(scheme: OastHttpScheme, method: OastHttpMethod, body_present: bool) -> Self {
642        Self {
643            scheme,
644            method,
645            body_present,
646        }
647    }
648
649    /// Returns the scheme.
650    pub const fn scheme(self) -> OastHttpScheme {
651        self.scheme
652    }
653
654    /// Returns the method.
655    pub const fn method(self) -> OastHttpMethod {
656        self.method
657    }
658
659    /// Returns whether a non-empty body was observed.
660    pub const fn body_present(self) -> bool {
661        self.body_present
662    }
663}
664
665/// Callback protocol family.
666#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
667pub enum OastEventProtocol {
668    /// DNS callback.
669    Dns,
670    /// HTTP callback.
671    Http,
672}
673
674/// Provider-neutral event with an opaque fixed-width identity.
675#[derive(Debug, Clone, PartialEq, Eq)]
676#[non_exhaustive]
677pub enum OastEvent {
678    /// DNS callback.
679    Dns(OastEventKey, OastDnsEvent),
680    /// HTTP callback.
681    Http(OastEventKey, OastHttpEvent),
682}
683
684impl OastEvent {
685    /// Returns the opaque event identity.
686    pub fn key(&self) -> &OastEventKey {
687        match self {
688            Self::Dns(key, _) | Self::Http(key, _) => key,
689        }
690    }
691
692    /// Returns the raw-free protocol family.
693    pub const fn protocol(&self) -> OastEventProtocol {
694        match self {
695            Self::Dns(_, _) => OastEventProtocol::Dns,
696            Self::Http(_, _) => OastEventProtocol::Http,
697        }
698    }
699}
700
701/// Result of atomically committing one event key.
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub enum OastEventDisposition {
704    /// The key was new.
705    Accepted,
706    /// The same key and protocol were already committed.
707    DuplicateSuppressed,
708}
709
710/// Terminal lifecycle state for one registration.
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum OastCorrelationState {
713    /// The registration is neither cancelled nor expired; budget is separate.
714    Active,
715    /// The host cancelled the registration.
716    Cancelled,
717    /// The exclusive expiry boundary was reached.
718    Expired,
719}
720
721/// Redacted receipt for one successful registration.
722#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct OastRegistrationReceipt {
724    binding_id: OastBindingId,
725    correlation_id: OastCorrelationId,
726    issued_at: OastMonotonicTime,
727    expires_at: OastMonotonicTime,
728    poll_limit: u16,
729    allowed_protocols: OastProtocolSet,
730}
731
732impl OastRegistrationReceipt {
733    /// Returns the non-secret binding identity.
734    pub fn binding_id(&self) -> &OastBindingId {
735        &self.binding_id
736    }
737
738    /// Returns the opaque correlation identity.
739    pub fn correlation_id(&self) -> &OastCorrelationId {
740        &self.correlation_id
741    }
742
743    /// Returns registration time.
744    pub const fn issued_at(&self) -> OastMonotonicTime {
745        self.issued_at
746    }
747
748    /// Returns exclusive expiry.
749    pub const fn expires_at(&self) -> OastMonotonicTime {
750        self.expires_at
751    }
752
753    /// Returns the granted poll count.
754    pub const fn poll_limit(&self) -> u16 {
755        self.poll_limit
756    }
757
758    /// Returns the granted protocols.
759    pub const fn allowed_protocols(&self) -> OastProtocolSet {
760        self.allowed_protocols
761    }
762}
763
764/// Redacted receipt for one event in an atomic poll commit.
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct OastEventReceipt {
767    event_key: OastEventKey,
768    protocol: OastEventProtocol,
769    disposition: OastEventDisposition,
770    observed_at: OastMonotonicTime,
771}
772
773impl OastEventReceipt {
774    /// Returns the opaque event key.
775    pub fn event_key(&self) -> &OastEventKey {
776        &self.event_key
777    }
778
779    /// Returns the protocol.
780    pub const fn protocol(&self) -> OastEventProtocol {
781        self.protocol
782    }
783
784    /// Returns the duplicate disposition.
785    pub const fn disposition(&self) -> OastEventDisposition {
786        self.disposition
787    }
788
789    /// Returns the injected observation time.
790    pub const fn observed_at(&self) -> OastMonotonicTime {
791        self.observed_at
792    }
793}
794
795/// Redacted receipt for one atomic poll commit.
796#[derive(Debug, Clone, PartialEq, Eq)]
797pub struct OastPollReceipt {
798    binding_id: OastBindingId,
799    correlation_id: OastCorrelationId,
800    poll_ordinal: u16,
801    completed_at: OastMonotonicTime,
802    event_receipts: Vec<OastEventReceipt>,
803    accepted_events: u16,
804    duplicate_events: u16,
805    remaining_polls: u16,
806}
807
808impl OastPollReceipt {
809    /// Returns the binding identity.
810    pub fn binding_id(&self) -> &OastBindingId {
811        &self.binding_id
812    }
813
814    /// Returns the correlation identity.
815    pub fn correlation_id(&self) -> &OastCorrelationId {
816        &self.correlation_id
817    }
818
819    /// Returns the one-based poll ordinal.
820    pub const fn poll_ordinal(&self) -> u16 {
821        self.poll_ordinal
822    }
823
824    /// Returns the completion time.
825    pub const fn completed_at(&self) -> OastMonotonicTime {
826        self.completed_at
827    }
828
829    /// Returns ordered typed event receipts.
830    pub fn event_receipts(&self) -> &[OastEventReceipt] {
831        &self.event_receipts
832    }
833
834    /// Returns accepted-event count for this poll.
835    pub const fn accepted_events(&self) -> u16 {
836        self.accepted_events
837    }
838
839    /// Returns suppressed-duplicate count for this poll.
840    pub const fn duplicate_events(&self) -> u16 {
841        self.duplicate_events
842    }
843
844    /// Returns polls remaining after permit issuance.
845    pub const fn remaining_polls(&self) -> u16 {
846        self.remaining_polls
847    }
848}
849
850/// Redacted receipt for cancellation or expiry.
851#[derive(Debug, Clone, PartialEq, Eq)]
852pub struct OastTerminalReceipt {
853    binding_id: OastBindingId,
854    correlation_id: OastCorrelationId,
855    state: OastCorrelationState,
856    terminal_at: OastMonotonicTime,
857}
858
859impl OastTerminalReceipt {
860    /// Returns the binding identity.
861    pub fn binding_id(&self) -> &OastBindingId {
862        &self.binding_id
863    }
864
865    /// Returns the correlation identity.
866    pub fn correlation_id(&self) -> &OastCorrelationId {
867        &self.correlation_id
868    }
869
870    /// Returns the terminal state.
871    pub const fn state(&self) -> OastCorrelationState {
872        self.state
873    }
874
875    /// Returns cancellation time or the exact expiry boundary.
876    pub const fn terminal_at(&self) -> OastMonotonicTime {
877        self.terminal_at
878    }
879}
880
881/// Bounded token-reuse authority for one explicit epoch.
882///
883/// Token fingerprints remain reserved until this value is dropped, including
884/// after registrations cancel or expire. This type intentionally has no
885/// Default implementation.
886pub struct OastCorrelationAuthority {
887    epoch: OastAuthorityEpoch,
888    assessment_id: OastAssessmentId,
889    limits: OastAuthorityLimits,
890    registered: u16,
891    token_fingerprints: BTreeSet<[u8; 32]>,
892}
893
894impl fmt::Debug for OastCorrelationAuthority {
895    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
896        formatter
897            .debug_struct("OastCorrelationAuthority")
898            .field("epoch", &self.epoch)
899            .field("assessment_id", &self.assessment_id)
900            .field("limits", &self.limits)
901            .field("registered", &self.registered)
902            .field("reserved_tokens", &self.token_fingerprints.len())
903            .finish()
904    }
905}
906
907impl OastCorrelationAuthority {
908    /// Starts an isolated authority epoch with explicit bounds.
909    pub fn new(
910        epoch: OastAuthorityEpoch,
911        assessment_id: OastAssessmentId,
912        limits: OastAuthorityLimits,
913    ) -> Self {
914        Self {
915            epoch,
916            assessment_id,
917            limits,
918            registered: 0,
919            token_fingerprints: BTreeSet::new(),
920        }
921    }
922
923    /// Returns the epoch identity.
924    pub fn epoch(&self) -> &OastAuthorityEpoch {
925        &self.epoch
926    }
927
928    /// Returns the exact assessment identity bound to this epoch.
929    pub fn assessment_id(&self) -> &OastAssessmentId {
930        &self.assessment_id
931    }
932
933    /// Returns the configured bounds.
934    pub const fn limits(&self) -> OastAuthorityLimits {
935        self.limits
936    }
937
938    /// Returns the number of tokens consumed in this epoch.
939    pub const fn registered(&self) -> u16 {
940        self.registered
941    }
942
943    /// Consumes a token and creates one exact bounded registration.
944    pub fn register(
945        &mut self,
946        verification_case: VerificationCase,
947        token: OastCorrelationToken,
948        allowed_protocols: OastProtocolSet,
949        issued_at: OastMonotonicTime,
950        lifetime: OastLifetime,
951        poll_budget: OastPollBudget,
952    ) -> Result<(OastCorrelation, OastRegistrationReceipt), OastError> {
953        validate_verification_binding(&verification_case)?;
954        if self.registered >= self.limits.max_registrations {
955            return Err(OastError::RegistrationCapacityExhausted);
956        }
957        if lifetime.0 > self.limits.max_lifetime_millis {
958            return Err(OastError::LifetimeGrantExceeded {
959                requested: lifetime.0,
960                maximum: self.limits.max_lifetime_millis,
961            });
962        }
963        if poll_budget.0 > self.limits.max_polls_per_registration {
964            return Err(OastError::PollGrantExceeded {
965                requested: poll_budget.0,
966                maximum: self.limits.max_polls_per_registration,
967            });
968        }
969        let expires_at = OastMonotonicTime(
970            issued_at
971                .0
972                .checked_add(lifetime.0)
973                .ok_or(OastError::ExpiryOverflow)?,
974        );
975        let token_fingerprint = token_fingerprint(&token);
976        if self.token_fingerprints.contains(&token_fingerprint) {
977            return Err(OastError::TokenAlreadyRegistered);
978        }
979
980        let assessment_id = self.assessment_id.clone();
981        let binding_id = binding_id(&BindingMaterial {
982            epoch: &self.epoch,
983            limits: self.limits,
984            assessment_id: &assessment_id,
985            verification_case: &verification_case,
986            protocols: allowed_protocols,
987            issued_at,
988            expires_at,
989            poll_budget,
990        });
991        let correlation_id = correlation_id(&token, &binding_id);
992        let receipt = OastRegistrationReceipt {
993            binding_id: binding_id.clone(),
994            correlation_id: correlation_id.clone(),
995            issued_at,
996            expires_at,
997            poll_limit: poll_budget.0,
998            allowed_protocols,
999        };
1000        self.token_fingerprints.insert(token_fingerprint);
1001        self.registered += 1;
1002        Ok((
1003            OastCorrelation {
1004                binding_id,
1005                correlation_id,
1006                assessment_id,
1007                verification_case,
1008                allowed_protocols,
1009                issued_at,
1010                expires_at,
1011                last_time: issued_at,
1012                state: OastCorrelationState::Active,
1013                terminal_at: None,
1014                poll_limit: poll_budget.0,
1015                remaining_polls: poll_budget.0,
1016                next_poll_ordinal: 1,
1017                max_events_per_poll: self.limits.max_events_per_poll,
1018                max_unique_events: self.limits.max_unique_events_per_registration,
1019                seen_events: BTreeMap::new(),
1020                accepted_events: 0,
1021                duplicate_events: 0,
1022                abandoned_polls: 0,
1023            },
1024            receipt,
1025        ))
1026    }
1027}
1028
1029/// One exact registration and its bounded correlation state.
1030pub struct OastCorrelation {
1031    binding_id: OastBindingId,
1032    correlation_id: OastCorrelationId,
1033    assessment_id: OastAssessmentId,
1034    verification_case: VerificationCase,
1035    allowed_protocols: OastProtocolSet,
1036    issued_at: OastMonotonicTime,
1037    expires_at: OastMonotonicTime,
1038    last_time: OastMonotonicTime,
1039    state: OastCorrelationState,
1040    terminal_at: Option<OastMonotonicTime>,
1041    poll_limit: u16,
1042    remaining_polls: u16,
1043    next_poll_ordinal: u16,
1044    max_events_per_poll: u16,
1045    max_unique_events: u16,
1046    seen_events: BTreeMap<OastEventKey, OastEventProtocol>,
1047    accepted_events: u16,
1048    duplicate_events: u16,
1049    abandoned_polls: u16,
1050}
1051
1052impl fmt::Debug for OastCorrelation {
1053    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1054        formatter
1055            .debug_struct("OastCorrelation")
1056            .field("binding_id", &self.binding_id)
1057            .field("correlation_id", &self.correlation_id)
1058            .field("assessment_id", &self.assessment_id)
1059            .field("verification_case", &"<redacted>")
1060            .field("allowed_protocols", &self.allowed_protocols)
1061            .field("issued_at", &self.issued_at)
1062            .field("expires_at", &self.expires_at)
1063            .field("state", &self.state)
1064            .field("poll_limit", &self.poll_limit)
1065            .field("remaining_polls", &self.remaining_polls)
1066            .field("accepted_events", &self.accepted_events)
1067            .field("duplicate_events", &self.duplicate_events)
1068            .field("abandoned_polls", &self.abandoned_polls)
1069            .finish()
1070    }
1071}
1072
1073impl OastCorrelation {
1074    /// Creates an isolated state snapshot for an atomic native-provider page.
1075    ///
1076    /// This is deliberately not a `Clone` implementation: arbitrary callers
1077    /// must not be able to fork correlation authority. The sealed native OAST
1078    /// adapter uses snapshots only to validate and commit a complete provider
1079    /// page before replacing the live states as one batch.
1080    #[cfg_attr(
1081        all(
1082            not(test),
1083            feature = "oast-correlation",
1084            not(feature = "oast-native-provider")
1085        ),
1086        expect(
1087            dead_code,
1088            reason = "transactional rollback is consumed only by the separately gated native provider adapter"
1089        )
1090    )]
1091    pub(crate) fn transactional_snapshot(&self) -> Self {
1092        Self {
1093            binding_id: self.binding_id.clone(),
1094            correlation_id: self.correlation_id.clone(),
1095            assessment_id: self.assessment_id.clone(),
1096            verification_case: self.verification_case.clone(),
1097            allowed_protocols: self.allowed_protocols,
1098            issued_at: self.issued_at,
1099            expires_at: self.expires_at,
1100            last_time: self.last_time,
1101            state: self.state,
1102            terminal_at: self.terminal_at,
1103            poll_limit: self.poll_limit,
1104            remaining_polls: self.remaining_polls,
1105            next_poll_ordinal: self.next_poll_ordinal,
1106            max_events_per_poll: self.max_events_per_poll,
1107            max_unique_events: self.max_unique_events,
1108            seen_events: self.seen_events.clone(),
1109            accepted_events: self.accepted_events,
1110            duplicate_events: self.duplicate_events,
1111            abandoned_polls: self.abandoned_polls,
1112        }
1113    }
1114
1115    /// Returns the binding identity.
1116    pub fn binding_id(&self) -> &OastBindingId {
1117        &self.binding_id
1118    }
1119
1120    /// Returns the correlation identity.
1121    pub fn correlation_id(&self) -> &OastCorrelationId {
1122        &self.correlation_id
1123    }
1124
1125    /// Returns the exact assessment identity.
1126    pub fn assessment_id(&self) -> &OastAssessmentId {
1127        &self.assessment_id
1128    }
1129
1130    /// Returns the complete verification case.
1131    pub fn verification_case(&self) -> &VerificationCase {
1132        &self.verification_case
1133    }
1134
1135    /// Returns the protocol grant.
1136    pub const fn allowed_protocols(&self) -> OastProtocolSet {
1137        self.allowed_protocols
1138    }
1139
1140    /// Returns registration time.
1141    pub const fn issued_at(&self) -> OastMonotonicTime {
1142        self.issued_at
1143    }
1144
1145    /// Returns exclusive expiry.
1146    pub const fn expires_at(&self) -> OastMonotonicTime {
1147        self.expires_at
1148    }
1149
1150    /// Returns the lifecycle state.
1151    pub const fn state(&self) -> OastCorrelationState {
1152        self.state
1153    }
1154
1155    /// Returns a typed terminal receipt after cancellation or observed expiry.
1156    pub fn terminal_receipt(&self) -> Option<OastTerminalReceipt> {
1157        self.terminal_at.map(|terminal_at| OastTerminalReceipt {
1158            binding_id: self.binding_id.clone(),
1159            correlation_id: self.correlation_id.clone(),
1160            state: self.state,
1161            terminal_at,
1162        })
1163    }
1164
1165    /// Returns the original poll grant.
1166    pub const fn poll_limit(&self) -> u16 {
1167        self.poll_limit
1168    }
1169
1170    /// Returns unspent polls.
1171    pub const fn remaining_polls(&self) -> u16 {
1172        self.remaining_polls
1173    }
1174
1175    /// Returns committed unique keys.
1176    pub fn unique_events(&self) -> usize {
1177        self.seen_events.len()
1178    }
1179
1180    /// Returns committed accepted-event count.
1181    pub const fn accepted_events(&self) -> u16 {
1182        self.accepted_events
1183    }
1184
1185    /// Returns committed duplicate count.
1186    pub const fn duplicate_events(&self) -> u16 {
1187        self.duplicate_events
1188    }
1189
1190    /// Returns permits dropped or consumed by a failed atomic completion.
1191    pub const fn abandoned_polls(&self) -> u16 {
1192        self.abandoned_polls
1193    }
1194
1195    /// Cancels an active registration after exact binding validation.
1196    pub fn cancel(
1197        &mut self,
1198        assessment_id: &OastAssessmentId,
1199        verification_case: &VerificationCase,
1200        now: OastMonotonicTime,
1201    ) -> Result<OastTerminalReceipt, OastError> {
1202        self.ensure_binding(assessment_id, verification_case)?;
1203        self.ensure_active_at(now)?;
1204        self.last_time = now;
1205        self.state = OastCorrelationState::Cancelled;
1206        self.terminal_at = Some(now);
1207        Ok(OastTerminalReceipt {
1208            binding_id: self.binding_id.clone(),
1209            correlation_id: self.correlation_id.clone(),
1210            state: OastCorrelationState::Cancelled,
1211            terminal_at: now,
1212        })
1213    }
1214
1215    /// Spends one poll and returns a move-only exclusive permit.
1216    ///
1217    /// Binding, state, time, expiry, and budget checks occur before the poll is
1218    /// spent. Dropping a permit never refunds the poll.
1219    pub fn begin_poll<'a>(
1220        &'a mut self,
1221        assessment_id: &OastAssessmentId,
1222        verification_case: &VerificationCase,
1223        now: OastMonotonicTime,
1224    ) -> Result<OastPollPermit<'a>, OastError> {
1225        self.ensure_binding(assessment_id, verification_case)?;
1226        self.ensure_active_at(now)?;
1227        if self.remaining_polls == 0 {
1228            return Err(OastError::PollBudgetExhausted);
1229        }
1230        let poll_ordinal = self.next_poll_ordinal;
1231        self.next_poll_ordinal += 1;
1232        self.remaining_polls -= 1;
1233        self.last_time = now;
1234        Ok(OastPollPermit {
1235            correlation: self,
1236            poll_ordinal,
1237            latest_observed_time: now,
1238            staged_events: Vec::new(),
1239            failure: None,
1240            committed: false,
1241        })
1242    }
1243
1244    fn ensure_binding(
1245        &self,
1246        assessment_id: &OastAssessmentId,
1247        verification_case: &VerificationCase,
1248    ) -> Result<(), OastError> {
1249        if assessment_id != &self.assessment_id || verification_case != &self.verification_case {
1250            return Err(OastError::BindingMismatch);
1251        }
1252        Ok(())
1253    }
1254
1255    fn ensure_active_at(&mut self, now: OastMonotonicTime) -> Result<(), OastError> {
1256        match self.state {
1257            OastCorrelationState::Cancelled => return Err(OastError::Cancelled),
1258            OastCorrelationState::Expired => {
1259                return Err(OastError::Expired {
1260                    expires_at: self.expires_at,
1261                });
1262            },
1263            OastCorrelationState::Active => {},
1264        }
1265        if now < self.last_time {
1266            return Err(OastError::ClockRegressed {
1267                previous: self.last_time,
1268                current: now,
1269            });
1270        }
1271        if now >= self.expires_at {
1272            self.state = OastCorrelationState::Expired;
1273            self.terminal_at = Some(self.expires_at);
1274            return Err(OastError::Expired {
1275                expires_at: self.expires_at,
1276            });
1277        }
1278        Ok(())
1279    }
1280}
1281
1282#[derive(Debug)]
1283struct StagedEvent {
1284    event: OastEvent,
1285    observed_at: OastMonotonicTime,
1286}
1287
1288/// Move-only authority to prepare one atomic poll commit.
1289///
1290/// The exclusive registration borrow prevents concurrent permits. Events stay
1291/// local to the permit until consuming completion validates the entire batch.
1292pub struct OastPollPermit<'a> {
1293    correlation: &'a mut OastCorrelation,
1294    poll_ordinal: u16,
1295    latest_observed_time: OastMonotonicTime,
1296    staged_events: Vec<StagedEvent>,
1297    failure: Option<OastError>,
1298    committed: bool,
1299}
1300
1301impl fmt::Debug for OastPollPermit<'_> {
1302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1303        formatter
1304            .debug_struct("OastPollPermit")
1305            .field("binding_id", &self.correlation.binding_id)
1306            .field("correlation_id", &self.correlation.correlation_id)
1307            .field("poll_ordinal", &self.poll_ordinal)
1308            .field("staged_events", &self.staged_events.len())
1309            .finish()
1310    }
1311}
1312
1313impl OastPollPermit<'_> {
1314    /// Returns the one-based poll ordinal.
1315    pub const fn poll_ordinal(&self) -> u16 {
1316        self.poll_ordinal
1317    }
1318
1319    /// Returns remaining polls after this permit was issued.
1320    pub const fn remaining_polls(&self) -> u16 {
1321        self.correlation.remaining_polls
1322    }
1323
1324    /// Returns the number of locally staged events.
1325    pub fn staged_events(&self) -> usize {
1326        self.staged_events.len()
1327    }
1328
1329    /// Stages one callback without mutating committed event state.
1330    pub fn stage_event(
1331        &mut self,
1332        correlation_id: &OastCorrelationId,
1333        event: OastEvent,
1334        observed_at: OastMonotonicTime,
1335    ) -> Result<(), OastError> {
1336        if let Some(error) = &self.failure {
1337            return Err(error.clone());
1338        }
1339        if correlation_id != &self.correlation.correlation_id {
1340            return self.reject(OastError::CorrelationMismatch);
1341        }
1342        let protocol = event.protocol();
1343        if !self.correlation.allowed_protocols.allows(protocol) {
1344            return self.reject(OastError::ProtocolNotAllowed { protocol });
1345        }
1346        if self.staged_events.len() >= usize::from(self.correlation.max_events_per_poll) {
1347            return self.reject(OastError::PollEventLimitExceeded {
1348                maximum: self.correlation.max_events_per_poll,
1349            });
1350        }
1351        if let Err(error) = self.correlation.ensure_active_at(observed_at) {
1352            return self.reject(error);
1353        }
1354        if observed_at > self.latest_observed_time {
1355            self.latest_observed_time = observed_at;
1356        }
1357        self.staged_events.push(StagedEvent { event, observed_at });
1358        Ok(())
1359    }
1360
1361    /// Validates and commits the staged batch atomically.
1362    pub fn finish(mut self, completed_at: OastMonotonicTime) -> Result<OastPollReceipt, OastError> {
1363        self.ensure_completion_time(completed_at)?;
1364        if let Some(error) = self.failure.take() {
1365            return Err(error);
1366        }
1367        self.staged_events.sort_by(|left, right| {
1368            left.event
1369                .key()
1370                .cmp(right.event.key())
1371                .then_with(|| left.event.protocol().cmp(&right.event.protocol()))
1372                .then_with(|| left.observed_at.cmp(&right.observed_at))
1373        });
1374
1375        let mut batch_protocols = BTreeMap::<OastEventKey, OastEventProtocol>::new();
1376        let mut receipts = Vec::with_capacity(self.staged_events.len());
1377        let mut accepted_events = 0u16;
1378        let mut duplicate_events = 0u16;
1379
1380        for staged in &self.staged_events {
1381            let key = staged.event.key();
1382            let protocol = staged.event.protocol();
1383            let known = self
1384                .correlation
1385                .seen_events
1386                .get(key)
1387                .or_else(|| batch_protocols.get(key));
1388            let disposition = match known {
1389                Some(existing) if *existing == protocol => {
1390                    duplicate_events += 1;
1391                    OastEventDisposition::DuplicateSuppressed
1392                },
1393                Some(_) => return Err(OastError::EventKeyProtocolConflict),
1394                None => {
1395                    accepted_events += 1;
1396                    batch_protocols.insert(key.clone(), protocol);
1397                    OastEventDisposition::Accepted
1398                },
1399            };
1400            receipts.push(OastEventReceipt {
1401                event_key: key.clone(),
1402                protocol,
1403                disposition,
1404                observed_at: staged.observed_at,
1405            });
1406        }
1407
1408        if self.correlation.seen_events.len() + batch_protocols.len()
1409            > usize::from(self.correlation.max_unique_events)
1410        {
1411            return Err(OastError::UniqueEventLimitExceeded {
1412                maximum: self.correlation.max_unique_events,
1413            });
1414        }
1415
1416        for (key, protocol) in batch_protocols {
1417            self.correlation.seen_events.insert(key, protocol);
1418        }
1419        self.correlation.accepted_events += accepted_events;
1420        self.correlation.duplicate_events += duplicate_events;
1421        self.correlation.last_time = completed_at;
1422        self.committed = true;
1423
1424        Ok(OastPollReceipt {
1425            binding_id: self.correlation.binding_id.clone(),
1426            correlation_id: self.correlation.correlation_id.clone(),
1427            poll_ordinal: self.poll_ordinal,
1428            completed_at,
1429            event_receipts: receipts,
1430            accepted_events,
1431            duplicate_events,
1432            remaining_polls: self.correlation.remaining_polls,
1433        })
1434    }
1435
1436    fn ensure_completion_time(&mut self, completed_at: OastMonotonicTime) -> Result<(), OastError> {
1437        self.correlation.ensure_active_at(completed_at)?;
1438        if completed_at < self.latest_observed_time {
1439            return Err(OastError::ClockRegressed {
1440                previous: self.latest_observed_time,
1441                current: completed_at,
1442            });
1443        }
1444        Ok(())
1445    }
1446
1447    fn reject<T>(&mut self, error: OastError) -> Result<T, OastError> {
1448        self.failure = Some(error.clone());
1449        Err(error)
1450    }
1451}
1452
1453impl Drop for OastPollPermit<'_> {
1454    fn drop(&mut self) {
1455        if !self.committed {
1456            self.correlation.abandoned_polls += 1;
1457        }
1458    }
1459}
1460
1461fn reject_zero(bytes: &[u8; 32]) -> Result<(), OastError> {
1462    if bytes.iter().all(|byte| *byte == 0) {
1463        return Err(OastError::ZeroOpaqueId);
1464    }
1465    Ok(())
1466}
1467
1468fn validate_verification_binding(verification_case: &VerificationCase) -> Result<(), OastError> {
1469    let components = [
1470        verification_case.id(),
1471        verification_case.subject().as_str(),
1472        verification_case.action_id(),
1473        verification_case.hypothesis_id(),
1474    ];
1475    if components
1476        .iter()
1477        .any(|component| component.len() > MAX_VERIFICATION_BINDING_COMPONENT_BYTES)
1478    {
1479        return Err(OastError::VerificationBindingTooLarge);
1480    }
1481    Ok(())
1482}
1483
1484fn token_fingerprint(token: &OastCorrelationToken) -> [u8; 32] {
1485    let mut hasher = Sha256::new();
1486    hasher.update(TOKEN_REUSE_DOMAIN);
1487    hash_field(&mut hasher, &token.secret_bytes);
1488    hasher.finalize().into()
1489}
1490
1491struct BindingMaterial<'a> {
1492    epoch: &'a OastAuthorityEpoch,
1493    limits: OastAuthorityLimits,
1494    assessment_id: &'a OastAssessmentId,
1495    verification_case: &'a VerificationCase,
1496    protocols: OastProtocolSet,
1497    issued_at: OastMonotonicTime,
1498    expires_at: OastMonotonicTime,
1499    poll_budget: OastPollBudget,
1500}
1501
1502fn binding_id(material: &BindingMaterial<'_>) -> OastBindingId {
1503    let mut hasher = Sha256::new();
1504    hasher.update(BINDING_ID_DOMAIN);
1505    hash_field(&mut hasher, &material.epoch.0);
1506    hash_field(&mut hasher, material.assessment_id.0.as_bytes());
1507    hash_verification_case(&mut hasher, material.verification_case);
1508    hasher.update([material.protocols.0]);
1509    hasher.update(material.issued_at.0.to_be_bytes());
1510    hasher.update(material.expires_at.0.to_be_bytes());
1511    hasher.update(material.poll_budget.0.to_be_bytes());
1512    hasher.update(material.limits.max_registrations.to_be_bytes());
1513    hasher.update(material.limits.max_polls_per_registration.to_be_bytes());
1514    hasher.update(material.limits.max_events_per_poll.to_be_bytes());
1515    hasher.update(
1516        material
1517            .limits
1518            .max_unique_events_per_registration
1519            .to_be_bytes(),
1520    );
1521    hasher.update(material.limits.max_lifetime_millis.to_be_bytes());
1522    OastBindingId(hasher.finalize().into())
1523}
1524
1525fn correlation_id(token: &OastCorrelationToken, binding_id: &OastBindingId) -> OastCorrelationId {
1526    let mut hasher = Sha256::new();
1527    hasher.update(CORRELATION_ID_DOMAIN);
1528    hash_field(&mut hasher, &token.secret_bytes);
1529    hash_field(&mut hasher, &binding_id.0);
1530    OastCorrelationId(hasher.finalize().into())
1531}
1532
1533fn hash_verification_case(hasher: &mut Sha256, verification_case: &VerificationCase) {
1534    hash_field(hasher, verification_case.id().as_bytes());
1535    hash_field(hasher, verification_case.subject().as_str().as_bytes());
1536    hash_field(hasher, verification_case.action_id().as_bytes());
1537    hash_field(hasher, verification_case.hypothesis_id().as_bytes());
1538    hasher.update([u8::from(verification_case.applies_hypothesis_transition())]);
1539    match verification_case.payload_strategy() {
1540        Some(strategy) => {
1541            hasher.update([1]);
1542            hash_field(hasher, strategy.id().as_bytes());
1543            hasher.update(strategy.revision().to_be_bytes());
1544        },
1545        None => hasher.update([0]),
1546    }
1547}
1548
1549fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
1550    hasher.update((bytes.len() as u128).to_be_bytes());
1551    hasher.update(bytes);
1552}
1553
1554#[cfg(all(test, feature = "oast-correlation"))]
1555mod tests {
1556    use super::*;
1557    use crate::payload_strategy::PayloadStrategyRef;
1558    use termivar_core::EntityId;
1559
1560    const SECRET: [u8; 32] = [
1561        0x91, 0x2f, 0x73, 0xa4, 0x05, 0x66, 0xb7, 0x18, 0xc9, 0x2a, 0xdb, 0x3c, 0xed, 0x4e, 0xff,
1562        0x50, 0x11, 0x82, 0x23, 0x94, 0x35, 0xa6, 0x47, 0xb8, 0x59, 0xca, 0x6b, 0xdc, 0x7d, 0xee,
1563        0x8f, 0xf0,
1564    ];
1565    const EPOCH: [u8; 32] = [
1566        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
1567        26, 27, 28, 29, 30, 31, 32,
1568    ];
1569
1570    fn assessment(value: &str) -> OastAssessmentId {
1571        OastAssessmentId::new(value).unwrap()
1572    }
1573
1574    fn subject(value: &str) -> EntityId {
1575        EntityId::new(value).unwrap()
1576    }
1577
1578    fn verification_case() -> VerificationCase {
1579        VerificationCase::new(
1580            "case:oast:1",
1581            subject("resource:oast:1"),
1582            "web.review.oast",
1583            "hypothesis:ssrf:1",
1584        )
1585        .unwrap()
1586    }
1587
1588    fn token(bytes: [u8; 32]) -> OastCorrelationToken {
1589        OastCorrelationToken::new(bytes).unwrap()
1590    }
1591
1592    fn epoch(bytes: [u8; 32]) -> OastAuthorityEpoch {
1593        OastAuthorityEpoch::new(bytes).unwrap()
1594    }
1595
1596    fn limits(
1597        registrations: u16,
1598        polls: u16,
1599        events_per_poll: u16,
1600        unique_events: u16,
1601    ) -> OastAuthorityLimits {
1602        OastAuthorityLimits::new(registrations, polls, events_per_poll, unique_events, 100).unwrap()
1603    }
1604
1605    fn protocols(dns: bool, http: bool) -> OastProtocolSet {
1606        OastProtocolSet::new(dns, http).unwrap()
1607    }
1608
1609    fn event_key(marker: u8) -> OastEventKey {
1610        let mut bytes = [0; 32];
1611        bytes[0] = marker;
1612        OastEventKey::new(bytes).unwrap()
1613    }
1614
1615    fn dns(marker: u8) -> OastEvent {
1616        OastEvent::Dns(
1617            event_key(marker),
1618            OastDnsEvent::new(OastDnsTransport::Udp, OastDnsRecordType::A),
1619        )
1620    }
1621
1622    fn http(marker: u8) -> OastEvent {
1623        OastEvent::Http(
1624            event_key(marker),
1625            OastHttpEvent::new(OastHttpScheme::Https, OastHttpMethod::Get, false),
1626        )
1627    }
1628
1629    fn register(
1630        authority: &mut OastCorrelationAuthority,
1631        secret: [u8; 32],
1632    ) -> (OastCorrelation, OastRegistrationReceipt) {
1633        authority
1634            .register(
1635                verification_case(),
1636                token(secret),
1637                protocols(true, true),
1638                OastMonotonicTime::from_millis(1_000),
1639                OastLifetime::from_millis(100).unwrap(),
1640                OastPollBudget::new(3).unwrap(),
1641            )
1642            .unwrap()
1643    }
1644
1645    fn authority() -> OastCorrelationAuthority {
1646        OastCorrelationAuthority::new(epoch(EPOCH), assessment("assessment:1"), limits(8, 4, 4, 8))
1647    }
1648
1649    #[test]
1650    fn constructors_enforce_exact_public_bounds() {
1651        assert_eq!(
1652            OastAssessmentId::new("").unwrap_err(),
1653            OastError::EmptyAssessmentId
1654        );
1655        assert_eq!(
1656            OastAssessmentId::new(" assessment:1").unwrap_err(),
1657            OastError::InvalidAssessmentId
1658        );
1659        assert_eq!(
1660            OastAssessmentId::new("x".repeat(MAX_ASSESSMENT_ID_BYTES + 1)).unwrap_err(),
1661            OastError::AssessmentIdTooLong {
1662                actual: MAX_ASSESSMENT_ID_BYTES + 1,
1663                maximum: MAX_ASSESSMENT_ID_BYTES,
1664            }
1665        );
1666        assert_eq!(
1667            OastCorrelationToken::new([0; 32]).unwrap_err(),
1668            OastError::ZeroOpaqueId
1669        );
1670        assert_eq!(
1671            OastAuthorityEpoch::new([0; 32]).unwrap_err(),
1672            OastError::ZeroOpaqueId
1673        );
1674        assert_eq!(
1675            OastEventKey::new([0; 32]).unwrap_err(),
1676            OastError::ZeroOpaqueId
1677        );
1678        assert_eq!(
1679            OastLifetime::from_millis(0).unwrap_err(),
1680            OastError::ZeroLifetime
1681        );
1682        assert_eq!(
1683            OastLifetime::from_millis(HARD_MAX_LIFETIME_MILLIS + 1).unwrap_err(),
1684            OastError::LifetimeTooLong {
1685                actual: HARD_MAX_LIFETIME_MILLIS + 1,
1686                maximum: HARD_MAX_LIFETIME_MILLIS,
1687            }
1688        );
1689        assert_eq!(
1690            OastPollBudget::new(0).unwrap_err(),
1691            OastError::ZeroPollBudget
1692        );
1693        assert_eq!(
1694            OastPollBudget::new(HARD_MAX_POLLS + 1).unwrap_err(),
1695            OastError::PollBudgetTooLarge {
1696                actual: HARD_MAX_POLLS + 1,
1697                maximum: HARD_MAX_POLLS,
1698            }
1699        );
1700        assert_eq!(
1701            OastAuthorityLimits::new(0, 1, 1, 1, 1).unwrap_err(),
1702            OastError::ZeroAuthorityLimit
1703        );
1704        assert_eq!(
1705            OastAuthorityLimits::new(HARD_MAX_REGISTRATIONS + 1, 1, 1, 1, 1).unwrap_err(),
1706            OastError::AuthorityLimitTooLarge
1707        );
1708        assert_eq!(
1709            OastProtocolSet::new(false, false).unwrap_err(),
1710            OastError::EmptyProtocolSet
1711        );
1712        assert_eq!(
1713            assessment("Assessment_1.v2:test").as_str(),
1714            "Assessment_1.v2:test"
1715        );
1716    }
1717
1718    #[test]
1719    fn public_bounds_and_receipts_expose_only_typed_state() {
1720        let authority_limits = limits(2, 2, 3, 4);
1721        assert_eq!(authority_limits.max_registrations(), 2);
1722        assert_eq!(authority_limits.max_polls_per_registration(), 2);
1723        assert_eq!(authority_limits.max_events_per_poll(), 3);
1724        assert_eq!(authority_limits.max_unique_events_per_registration(), 4);
1725        assert_eq!(authority_limits.max_lifetime_millis(), 100);
1726        assert_eq!(OastLifetime::from_millis(100).unwrap().as_millis(), 100);
1727        assert_eq!(OastPollBudget::new(2).unwrap().polls(), 2);
1728
1729        let mut authority = OastCorrelationAuthority::new(
1730            epoch(EPOCH),
1731            assessment("assessment:1"),
1732            authority_limits,
1733        );
1734        assert_eq!(authority.epoch(), &epoch(EPOCH));
1735        assert_eq!(authority.limits(), authority_limits);
1736        let (mut correlation, registration) = authority
1737            .register(
1738                verification_case(),
1739                token(SECRET),
1740                protocols(true, true),
1741                OastMonotonicTime::from_millis(1_000),
1742                OastLifetime::from_millis(100).unwrap(),
1743                OastPollBudget::new(2).unwrap(),
1744            )
1745            .unwrap();
1746        assert_eq!(registration.binding_id(), correlation.binding_id());
1747        assert_eq!(registration.correlation_id(), correlation.correlation_id());
1748        assert_eq!(registration.issued_at().as_millis(), 1_000);
1749        assert_eq!(registration.expires_at().as_millis(), 1_100);
1750        assert_eq!(registration.poll_limit(), 2);
1751        assert!(registration.allowed_protocols().allows_dns());
1752        assert!(registration.allowed_protocols().allows_http());
1753        assert_eq!(correlation.issued_at(), registration.issued_at());
1754        assert_eq!(correlation.expires_at(), registration.expires_at());
1755        assert_eq!(correlation.poll_limit(), registration.poll_limit());
1756        assert_eq!(correlation.state(), OastCorrelationState::Active);
1757        assert!(correlation.terminal_receipt().is_none());
1758
1759        let id = correlation.correlation_id().clone();
1760        let bound_assessment = correlation.assessment_id().clone();
1761        let case = correlation.verification_case().clone();
1762        let mut permit = correlation
1763            .begin_poll(
1764                &bound_assessment,
1765                &case,
1766                OastMonotonicTime::from_millis(1_001),
1767            )
1768            .unwrap();
1769        assert_eq!(permit.poll_ordinal(), 1);
1770        assert_eq!(permit.remaining_polls(), 1);
1771        assert_eq!(permit.staged_events(), 0);
1772        assert!(format!("{permit:?}").contains("staged_events: 0"));
1773        permit
1774            .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_002))
1775            .unwrap();
1776        assert_eq!(permit.staged_events(), 1);
1777        let receipt = permit
1778            .finish(OastMonotonicTime::from_millis(1_003))
1779            .unwrap();
1780        assert_eq!(receipt.poll_ordinal(), 1);
1781        assert_eq!(receipt.completed_at().as_millis(), 1_003);
1782        assert_eq!(receipt.remaining_polls(), 1);
1783        let event = &receipt.event_receipts()[0];
1784        assert_eq!(event.event_key(), &event_key(1));
1785        assert_eq!(event.protocol(), OastEventProtocol::Http);
1786        assert_eq!(event.disposition(), OastEventDisposition::Accepted);
1787        assert_eq!(event.observed_at().as_millis(), 1_002);
1788
1789        let terminal = correlation
1790            .cancel(
1791                &bound_assessment,
1792                &case,
1793                OastMonotonicTime::from_millis(1_004),
1794            )
1795            .unwrap();
1796        assert_eq!(terminal.binding_id(), registration.binding_id());
1797        assert_eq!(terminal.correlation_id(), registration.correlation_id());
1798    }
1799
1800    #[test]
1801    fn every_error_has_a_value_safe_typed_display() {
1802        let errors = [
1803            (OastError::EmptyAssessmentId, "OAST assessment id is empty"),
1804            (
1805                OastError::AssessmentIdTooLong {
1806                    actual: 257,
1807                    maximum: 256,
1808                },
1809                "OAST assessment id length 257 exceeds 256",
1810            ),
1811            (
1812                OastError::InvalidAssessmentId,
1813                "OAST assessment id is invalid",
1814            ),
1815            (
1816                OastError::ZeroOpaqueId,
1817                "OAST opaque id must not be all zero",
1818            ),
1819            (OastError::ZeroLifetime, "OAST lifetime must be non-zero"),
1820            (
1821                OastError::LifetimeTooLong {
1822                    actual: 101,
1823                    maximum: 100,
1824                },
1825                "OAST lifetime 101ms exceeds 100ms",
1826            ),
1827            (
1828                OastError::ZeroPollBudget,
1829                "OAST poll budget must be non-zero",
1830            ),
1831            (
1832                OastError::PollBudgetTooLarge {
1833                    actual: 2,
1834                    maximum: 1,
1835                },
1836                "OAST poll budget 2 exceeds 1",
1837            ),
1838            (
1839                OastError::ZeroAuthorityLimit,
1840                "OAST authority limits must be non-zero",
1841            ),
1842            (
1843                OastError::AuthorityLimitTooLarge,
1844                "OAST authority limit exceeds a hard foundation bound",
1845            ),
1846            (
1847                OastError::EmptyProtocolSet,
1848                "OAST protocol set must not be empty",
1849            ),
1850            (
1851                OastError::RegistrationCapacityExhausted,
1852                "OAST registration capacity is exhausted",
1853            ),
1854            (
1855                OastError::TokenAlreadyRegistered,
1856                "OAST token was already registered in this authority epoch",
1857            ),
1858            (
1859                OastError::VerificationBindingTooLarge,
1860                "OAST verification binding exceeds its component bound",
1861            ),
1862            (
1863                OastError::LifetimeGrantExceeded {
1864                    requested: 101,
1865                    maximum: 100,
1866                },
1867                "OAST lifetime 101ms exceeds grant 100ms",
1868            ),
1869            (
1870                OastError::PollGrantExceeded {
1871                    requested: 2,
1872                    maximum: 1,
1873                },
1874                "OAST poll count 2 exceeds grant 1",
1875            ),
1876            (
1877                OastError::ExpiryOverflow,
1878                "OAST expiry overflowed monotonic time",
1879            ),
1880            (OastError::BindingMismatch, "OAST binding does not match"),
1881            (
1882                OastError::CorrelationMismatch,
1883                "OAST correlation does not match",
1884            ),
1885            (
1886                OastError::ProtocolNotAllowed {
1887                    protocol: OastEventProtocol::Http,
1888                },
1889                "OAST protocol Http is not allowed",
1890            ),
1891            (
1892                OastError::ClockRegressed {
1893                    previous: OastMonotonicTime::from_millis(2),
1894                    current: OastMonotonicTime::from_millis(1),
1895                },
1896                "OAST time regressed from 2ms to 1ms",
1897            ),
1898            (
1899                OastError::Expired {
1900                    expires_at: OastMonotonicTime::from_millis(9),
1901                },
1902                "OAST correlation expired at 9ms",
1903            ),
1904            (OastError::Cancelled, "OAST correlation is cancelled"),
1905            (
1906                OastError::PollBudgetExhausted,
1907                "OAST poll budget is exhausted",
1908            ),
1909            (
1910                OastError::PollEventLimitExceeded { maximum: 3 },
1911                "OAST poll exceeds its 3-event bound",
1912            ),
1913            (
1914                OastError::UniqueEventLimitExceeded { maximum: 4 },
1915                "OAST registration exceeds its 4-event bound",
1916            ),
1917            (
1918                OastError::EventKeyProtocolConflict,
1919                "OAST event key has conflicting protocol families",
1920            ),
1921        ];
1922        for (error, expected) in errors {
1923            assert_eq!(error.to_string(), expected);
1924            let as_error: &dyn Error = &error;
1925            assert!(as_error.source().is_none());
1926        }
1927    }
1928
1929    #[test]
1930    fn expiry_overflow_and_exhausted_poll_budget_fail_closed() {
1931        let mut overflow_authority = OastCorrelationAuthority::new(
1932            epoch(EPOCH),
1933            assessment("assessment:1"),
1934            limits(2, 1, 1, 1),
1935        );
1936        assert_eq!(
1937            overflow_authority
1938                .register(
1939                    verification_case(),
1940                    token(SECRET),
1941                    protocols(true, false),
1942                    OastMonotonicTime::from_millis(u64::MAX),
1943                    OastLifetime::from_millis(1).unwrap(),
1944                    OastPollBudget::new(1).unwrap(),
1945                )
1946                .unwrap_err(),
1947            OastError::ExpiryOverflow
1948        );
1949        assert_eq!(overflow_authority.registered(), 0);
1950
1951        let (mut correlation, _) = overflow_authority
1952            .register(
1953                verification_case(),
1954                token(SECRET),
1955                protocols(true, false),
1956                OastMonotonicTime::from_millis(1_000),
1957                OastLifetime::from_millis(100).unwrap(),
1958                OastPollBudget::new(1).unwrap(),
1959            )
1960            .unwrap();
1961        let bound_assessment = correlation.assessment_id().clone();
1962        let case = correlation.verification_case().clone();
1963        correlation
1964            .begin_poll(
1965                &bound_assessment,
1966                &case,
1967                OastMonotonicTime::from_millis(1_001),
1968            )
1969            .unwrap()
1970            .finish(OastMonotonicTime::from_millis(1_002))
1971            .unwrap();
1972        assert_eq!(correlation.remaining_polls(), 0);
1973        assert_eq!(
1974            correlation
1975                .begin_poll(
1976                    &bound_assessment,
1977                    &case,
1978                    OastMonotonicTime::from_millis(1_003),
1979                )
1980                .unwrap_err(),
1981            OastError::PollBudgetExhausted
1982        );
1983    }
1984
1985    #[test]
1986    fn secret_and_opaque_debug_output_is_redacted() {
1987        let mut secret = token(SECRET);
1988        assert_eq!(format!("{secret:?}"), "OastCorrelationToken(<redacted>)");
1989        secret.erase();
1990        assert_eq!(secret.secret_bytes, [0; 32]);
1991        assert!(std::mem::needs_drop::<OastCorrelationToken>());
1992
1993        let mut authority = authority();
1994        let (correlation, receipt) = register(&mut authority, SECRET);
1995        let rendered = format!(
1996            "{authority:?} {correlation:?} {receipt:?} {:?} {:?} {:?}",
1997            epoch(EPOCH),
1998            receipt.binding_id(),
1999            event_key(9)
2000        );
2001        for raw in [
2002            "assessment:1",
2003            "case:oast:1",
2004            "resource:oast:1",
2005            "hypothesis:ssrf:1",
2006            "145, 47, 115",
2007        ] {
2008            assert!(!rendered.contains(raw));
2009        }
2010        assert!(rendered.matches("<redacted>").count() >= 5);
2011    }
2012
2013    #[test]
2014    fn authority_rejects_reuse_and_capacity_without_forgetting_terminal_tokens() {
2015        let mut authority = OastCorrelationAuthority::new(
2016            epoch(EPOCH),
2017            assessment("assessment:1"),
2018            limits(2, 3, 3, 3),
2019        );
2020        let (mut first, _) = register(&mut authority, SECRET);
2021        let bound_assessment = first.assessment_id().clone();
2022        let case = first.verification_case().clone();
2023        let cancelled = first
2024            .cancel(
2025                &bound_assessment,
2026                &case,
2027                OastMonotonicTime::from_millis(1_001),
2028            )
2029            .unwrap();
2030        assert_eq!(cancelled.state(), OastCorrelationState::Cancelled);
2031        assert_eq!(
2032            authority
2033                .register(
2034                    verification_case(),
2035                    token(SECRET),
2036                    protocols(true, false),
2037                    OastMonotonicTime::from_millis(2_000),
2038                    OastLifetime::from_millis(50).unwrap(),
2039                    OastPollBudget::new(1).unwrap(),
2040                )
2041                .unwrap_err(),
2042            OastError::TokenAlreadyRegistered
2043        );
2044
2045        let mut second_secret = SECRET;
2046        second_secret[0] ^= 1;
2047        register(&mut authority, second_secret);
2048        let mut third_secret = SECRET;
2049        third_secret[1] ^= 1;
2050        assert_eq!(
2051            authority
2052                .register(
2053                    verification_case(),
2054                    token(third_secret),
2055                    protocols(true, true),
2056                    OastMonotonicTime::from_millis(3_000),
2057                    OastLifetime::from_millis(50).unwrap(),
2058                    OastPollBudget::new(1).unwrap(),
2059                )
2060                .unwrap_err(),
2061            OastError::RegistrationCapacityExhausted
2062        );
2063        assert_eq!(authority.registered(), 2);
2064    }
2065
2066    #[test]
2067    fn registration_enforces_authority_grants_before_mutation() {
2068        let mut authority = OastCorrelationAuthority::new(
2069            epoch(EPOCH),
2070            assessment("assessment:1"),
2071            limits(2, 1, 1, 1),
2072        );
2073        assert_eq!(authority.assessment_id(), &assessment("assessment:1"));
2074        assert_eq!(
2075            authority
2076                .register(
2077                    verification_case(),
2078                    token(SECRET),
2079                    protocols(true, false),
2080                    OastMonotonicTime::from_millis(0),
2081                    OastLifetime::from_millis(101).unwrap(),
2082                    OastPollBudget::new(1).unwrap(),
2083                )
2084                .unwrap_err(),
2085            OastError::LifetimeGrantExceeded {
2086                requested: 101,
2087                maximum: 100,
2088            }
2089        );
2090        assert_eq!(
2091            authority
2092                .register(
2093                    verification_case(),
2094                    token(SECRET),
2095                    protocols(true, false),
2096                    OastMonotonicTime::from_millis(0),
2097                    OastLifetime::from_millis(100).unwrap(),
2098                    OastPollBudget::new(2).unwrap(),
2099                )
2100                .unwrap_err(),
2101            OastError::PollGrantExceeded {
2102                requested: 2,
2103                maximum: 1,
2104            }
2105        );
2106        assert_eq!(authority.registered(), 0);
2107        authority
2108            .register(
2109                verification_case(),
2110                token(SECRET),
2111                protocols(true, false),
2112                OastMonotonicTime::from_millis(0),
2113                OastLifetime::from_millis(100).unwrap(),
2114                OastPollBudget::new(1).unwrap(),
2115            )
2116            .unwrap();
2117        assert_eq!(authority.registered(), 1);
2118    }
2119
2120    #[test]
2121    fn registration_bounds_every_verification_identity_before_hashing() {
2122        let oversized = "x".repeat(MAX_VERIFICATION_BINDING_COMPONENT_BYTES + 1);
2123        let cases = [
2124            VerificationCase::new(
2125                oversized.clone(),
2126                subject("resource:oast:1"),
2127                "action:oast:1",
2128                "hypothesis:ssrf:1",
2129            )
2130            .unwrap(),
2131            VerificationCase::new(
2132                "case:oast:1",
2133                subject(&oversized),
2134                "action:oast:1",
2135                "hypothesis:ssrf:1",
2136            )
2137            .unwrap(),
2138            VerificationCase::new(
2139                "case:oast:1",
2140                subject("resource:oast:1"),
2141                oversized.clone(),
2142                "hypothesis:ssrf:1",
2143            )
2144            .unwrap(),
2145            VerificationCase::new(
2146                "case:oast:1",
2147                subject("resource:oast:1"),
2148                "action:oast:1",
2149                oversized,
2150            )
2151            .unwrap(),
2152        ];
2153        let mut authority = authority();
2154        for case in cases {
2155            assert_eq!(
2156                authority
2157                    .register(
2158                        case,
2159                        token(SECRET),
2160                        protocols(true, true),
2161                        OastMonotonicTime::from_millis(1_000),
2162                        OastLifetime::from_millis(100).unwrap(),
2163                        OastPollBudget::new(1).unwrap(),
2164                    )
2165                    .unwrap_err(),
2166                OastError::VerificationBindingTooLarge
2167            );
2168            assert_eq!(authority.registered(), 0);
2169        }
2170
2171        let maximum = "x".repeat(MAX_VERIFICATION_BINDING_COMPONENT_BYTES);
2172        let maximum_case =
2173            VerificationCase::new(maximum.clone(), subject(&maximum), maximum.clone(), maximum)
2174                .unwrap();
2175        authority
2176            .register(
2177                maximum_case,
2178                token(SECRET),
2179                protocols(true, true),
2180                OastMonotonicTime::from_millis(1_000),
2181                OastLifetime::from_millis(100).unwrap(),
2182                OastPollBudget::new(1).unwrap(),
2183            )
2184            .unwrap();
2185        assert_eq!(authority.registered(), 1);
2186    }
2187
2188    #[test]
2189    fn binding_and_correlation_ids_cover_every_semantic_dimension() {
2190        #[derive(Clone)]
2191        struct IdInputs {
2192            epoch_bytes: [u8; 32],
2193            authority_limits: OastAuthorityLimits,
2194            assessment_id: OastAssessmentId,
2195            case: VerificationCase,
2196            secret: [u8; 32],
2197            allowed_protocols: OastProtocolSet,
2198            issued_at: u64,
2199            lifetime: u64,
2200            polls: u16,
2201        }
2202
2203        fn ids(inputs: IdInputs) -> (OastBindingId, OastCorrelationId) {
2204            let mut authority = OastCorrelationAuthority::new(
2205                epoch(inputs.epoch_bytes),
2206                inputs.assessment_id.clone(),
2207                inputs.authority_limits,
2208            );
2209            let receipt = authority
2210                .register(
2211                    inputs.case,
2212                    token(inputs.secret),
2213                    inputs.allowed_protocols,
2214                    OastMonotonicTime::from_millis(inputs.issued_at),
2215                    OastLifetime::from_millis(inputs.lifetime).unwrap(),
2216                    OastPollBudget::new(inputs.polls).unwrap(),
2217                )
2218                .unwrap()
2219                .1;
2220            (receipt.binding_id, receipt.correlation_id)
2221        }
2222
2223        let base_limits = limits(8, 4, 4, 8);
2224        let inputs = IdInputs {
2225            epoch_bytes: EPOCH,
2226            authority_limits: base_limits,
2227            assessment_id: assessment("assessment:1"),
2228            case: verification_case(),
2229            secret: SECRET,
2230            allowed_protocols: protocols(true, true),
2231            issued_at: 1_000,
2232            lifetime: 100,
2233            polls: 3,
2234        };
2235        let baseline = ids(inputs.clone());
2236        assert_eq!(baseline, ids(inputs.clone()));
2237
2238        let mut changed_epoch = EPOCH;
2239        changed_epoch[0] ^= 1;
2240        let changed_cases = [
2241            VerificationCase::new(
2242                "case:oast:2",
2243                subject("resource:oast:1"),
2244                "web.review.oast",
2245                "hypothesis:ssrf:1",
2246            )
2247            .unwrap(),
2248            VerificationCase::new(
2249                "case:oast:1",
2250                subject("resource:oast:2"),
2251                "web.review.oast",
2252                "hypothesis:ssrf:1",
2253            )
2254            .unwrap(),
2255            VerificationCase::new(
2256                "case:oast:1",
2257                subject("resource:oast:1"),
2258                "web.review.other",
2259                "hypothesis:ssrf:1",
2260            )
2261            .unwrap(),
2262            VerificationCase::new(
2263                "case:oast:1",
2264                subject("resource:oast:1"),
2265                "web.review.oast",
2266                "hypothesis:ssrf:2",
2267            )
2268            .unwrap(),
2269            verification_case().without_hypothesis_transition(),
2270            verification_case()
2271                .with_payload_strategy(Some(PayloadStrategyRef::new("strategy:oast", 1).unwrap())),
2272        ];
2273        for case in changed_cases {
2274            let mut changed = inputs.clone();
2275            changed.case = case;
2276            assert_ne!(baseline.0, ids(changed).0);
2277        }
2278
2279        let variations = [
2280            IdInputs {
2281                epoch_bytes: changed_epoch,
2282                ..inputs.clone()
2283            },
2284            IdInputs {
2285                assessment_id: assessment("assessment:2"),
2286                ..inputs.clone()
2287            },
2288            IdInputs {
2289                allowed_protocols: protocols(true, false),
2290                ..inputs.clone()
2291            },
2292            IdInputs {
2293                issued_at: 1_001,
2294                lifetime: 99,
2295                ..inputs.clone()
2296            },
2297            IdInputs {
2298                authority_limits: limits(9, 4, 4, 8),
2299                ..inputs.clone()
2300            },
2301            IdInputs {
2302                polls: 2,
2303                ..inputs.clone()
2304            },
2305        ];
2306        for variation in variations.map(ids) {
2307            assert_ne!(baseline.0, variation.0);
2308            assert_ne!(baseline.1, variation.1);
2309        }
2310
2311        let mut changed_secret = SECRET;
2312        changed_secret[31] ^= 1;
2313        let secret_variation = ids(IdInputs {
2314            secret: changed_secret,
2315            ..inputs
2316        });
2317        assert_eq!(baseline.0, secret_variation.0);
2318        assert_ne!(baseline.1, secret_variation.1);
2319    }
2320
2321    #[test]
2322    fn v1_identity_algorithms_match_fixed_golden_vectors() {
2323        assert_eq!(
2324            token_fingerprint(&token(SECRET)),
2325            [
2326                0x16, 0x55, 0x21, 0x51, 0xf8, 0xe5, 0xb0, 0xa6, 0x85, 0x0c, 0x39, 0x32, 0x1a, 0xbc,
2327                0x13, 0xa2, 0xb6, 0x03, 0xbe, 0x3b, 0x35, 0x3d, 0xc1, 0xf5, 0x88, 0x61, 0x03, 0xaf,
2328                0xc5, 0x57, 0xb0, 0xc9,
2329            ]
2330        );
2331
2332        let mut authority = authority();
2333        let (_, receipt) = register(&mut authority, SECRET);
2334        assert_eq!(
2335            receipt.binding_id.0,
2336            [
2337                0x8e, 0xdd, 0xd8, 0x7b, 0x7b, 0x81, 0x66, 0x7c, 0xf3, 0xff, 0x38, 0x8b, 0x13, 0x63,
2338                0x5e, 0x7e, 0x61, 0xc4, 0xc6, 0xed, 0x3b, 0x89, 0xfb, 0x0a, 0x50, 0x72, 0x32, 0x4d,
2339                0x6e, 0x05, 0x03, 0xb3,
2340            ]
2341        );
2342        assert_eq!(
2343            receipt.correlation_id.0,
2344            [
2345                0x6e, 0x91, 0x58, 0x98, 0x5c, 0x84, 0x6f, 0xf0, 0xf0, 0x98, 0xf9, 0x37, 0xba, 0xe3,
2346                0x7e, 0x00, 0x5d, 0x1a, 0x6e, 0x0f, 0xf4, 0xd9, 0x33, 0x00, 0xa0, 0x11, 0x72, 0xd1,
2347                0x1b, 0xf0, 0x92, 0xc7,
2348            ]
2349        );
2350    }
2351
2352    #[test]
2353    fn exact_binding_and_poll_budget_fail_without_accidental_spend() {
2354        let mut authority = authority();
2355        let (mut correlation, _) = register(&mut authority, SECRET);
2356        let correct_assessment = correlation.assessment_id().clone();
2357        let correct_case = correlation.verification_case().clone();
2358        let initial = correlation.remaining_polls();
2359        assert_eq!(
2360            correlation
2361                .begin_poll(
2362                    &assessment("assessment:wrong"),
2363                    &correct_case,
2364                    OastMonotonicTime::from_millis(1_001),
2365                )
2366                .unwrap_err(),
2367            OastError::BindingMismatch
2368        );
2369        let wrong_case = VerificationCase::new(
2370            correct_case.id(),
2371            subject("resource:wrong"),
2372            correct_case.action_id(),
2373            correct_case.hypothesis_id(),
2374        )
2375        .unwrap();
2376        assert_eq!(
2377            correlation
2378                .begin_poll(
2379                    &correct_assessment,
2380                    &wrong_case,
2381                    OastMonotonicTime::from_millis(1_001),
2382                )
2383                .unwrap_err(),
2384            OastError::BindingMismatch
2385        );
2386        assert_eq!(correlation.remaining_polls(), initial);
2387
2388        drop(
2389            correlation
2390                .begin_poll(
2391                    &correct_assessment,
2392                    &correct_case,
2393                    OastMonotonicTime::from_millis(1_001),
2394                )
2395                .unwrap(),
2396        );
2397        assert_eq!(correlation.remaining_polls(), initial - 1);
2398        assert_eq!(correlation.abandoned_polls(), 1);
2399    }
2400
2401    #[test]
2402    fn event_models_are_typed_raw_free_and_protocol_grants_fail_closed() {
2403        let dns_data = OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt);
2404        assert_eq!(dns_data.transport(), OastDnsTransport::Tcp);
2405        assert_eq!(dns_data.record_type(), OastDnsRecordType::Txt);
2406        let http_data = OastHttpEvent::new(OastHttpScheme::Https, OastHttpMethod::Post, true);
2407        assert_eq!(http_data.scheme(), OastHttpScheme::Https);
2408        assert_eq!(http_data.method(), OastHttpMethod::Post);
2409        assert!(http_data.body_present());
2410
2411        let mut authority = OastCorrelationAuthority::new(
2412            epoch(EPOCH),
2413            assessment("assessment:1"),
2414            limits(2, 2, 2, 2),
2415        );
2416        let (mut correlation, _) = authority
2417            .register(
2418                verification_case(),
2419                token(SECRET),
2420                protocols(true, false),
2421                OastMonotonicTime::from_millis(1_000),
2422                OastLifetime::from_millis(100).unwrap(),
2423                OastPollBudget::new(2).unwrap(),
2424            )
2425            .unwrap();
2426        assert!(correlation.allowed_protocols().allows_dns());
2427        assert!(!correlation.allowed_protocols().allows_http());
2428        let id = correlation.correlation_id().clone();
2429        let assessment = correlation.assessment_id().clone();
2430        let case = correlation.verification_case().clone();
2431        let mut permit = correlation
2432            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2433            .unwrap();
2434        let error = OastError::ProtocolNotAllowed {
2435            protocol: OastEventProtocol::Http,
2436        };
2437        assert_eq!(
2438            permit
2439                .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_002))
2440                .unwrap_err(),
2441            error
2442        );
2443        assert_eq!(
2444            permit
2445                .finish(OastMonotonicTime::from_millis(1_003))
2446                .unwrap_err(),
2447            error
2448        );
2449        assert_eq!(correlation.unique_events(), 0);
2450    }
2451
2452    #[test]
2453    fn atomic_completion_uses_event_key_and_protocol_family() {
2454        let mut authority = authority();
2455        let (mut correlation, registration) = register(&mut authority, SECRET);
2456        let id = correlation.correlation_id().clone();
2457        let assessment = correlation.assessment_id().clone();
2458        let case = correlation.verification_case().clone();
2459
2460        let mut first = correlation
2461            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2462            .unwrap();
2463        first
2464            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2465            .unwrap();
2466        first
2467            .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2468            .unwrap();
2469        let first_receipt = first.finish(OastMonotonicTime::from_millis(1_004)).unwrap();
2470        assert_eq!(first_receipt.accepted_events(), 2);
2471        assert_eq!(first_receipt.duplicate_events(), 0);
2472        assert_eq!(first_receipt.event_receipts().len(), 2);
2473        assert_eq!(first_receipt.binding_id(), registration.binding_id());
2474        assert_eq!(
2475            first_receipt.correlation_id(),
2476            registration.correlation_id()
2477        );
2478
2479        let mut second = correlation
2480            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_005))
2481            .unwrap();
2482        second
2483            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_006))
2484            .unwrap();
2485        second
2486            .stage_event(
2487                &id,
2488                OastEvent::Dns(
2489                    event_key(3),
2490                    OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2491                ),
2492                OastMonotonicTime::from_millis(1_007),
2493            )
2494            .unwrap();
2495        second
2496            .stage_event(
2497                &id,
2498                OastEvent::Dns(
2499                    event_key(3),
2500                    OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2501                ),
2502                OastMonotonicTime::from_millis(1_008),
2503            )
2504            .unwrap();
2505        let second_receipt = second
2506            .finish(OastMonotonicTime::from_millis(1_009))
2507            .unwrap();
2508        assert_eq!(second_receipt.accepted_events(), 1);
2509        assert_eq!(second_receipt.duplicate_events(), 2);
2510        assert_eq!(
2511            second_receipt.event_receipts()[0].disposition(),
2512            OastEventDisposition::DuplicateSuppressed
2513        );
2514        assert_eq!(correlation.unique_events(), 3);
2515        assert_eq!(correlation.accepted_events(), 3);
2516        assert_eq!(correlation.duplicate_events(), 2);
2517        assert_eq!(correlation.abandoned_polls(), 0);
2518    }
2519
2520    #[test]
2521    fn poll_receipts_are_canonical_across_provider_batch_order() {
2522        fn complete(order: [u8; 3]) -> OastPollReceipt {
2523            let mut authority = authority();
2524            let (mut correlation, _) = register(&mut authority, SECRET);
2525            let id = correlation.correlation_id().clone();
2526            let assessment = correlation.assessment_id().clone();
2527            let case = correlation.verification_case().clone();
2528            let mut permit = correlation
2529                .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2530                .unwrap();
2531            for marker in order {
2532                permit
2533                    .stage_event(
2534                        &id,
2535                        dns(marker),
2536                        OastMonotonicTime::from_millis(1_010 + u64::from(marker)),
2537                    )
2538                    .unwrap();
2539            }
2540            permit
2541                .finish(OastMonotonicTime::from_millis(1_020))
2542                .unwrap()
2543        }
2544
2545        let ascending = complete([1, 2, 3]);
2546        let reordered = complete([3, 1, 2]);
2547        assert_eq!(ascending, reordered);
2548        assert_eq!(
2549            ascending
2550                .event_receipts()
2551                .iter()
2552                .map(|receipt| receipt.event_key().clone())
2553                .collect::<Vec<_>>(),
2554            vec![event_key(1), event_key(2), event_key(3)]
2555        );
2556    }
2557
2558    #[test]
2559    fn same_key_same_protocol_is_duplicate_despite_metadata_changes() {
2560        let mut authority = authority();
2561        let (mut correlation, _) = register(&mut authority, SECRET);
2562        let id = correlation.correlation_id().clone();
2563        let assessment = correlation.assessment_id().clone();
2564        let case = correlation.verification_case().clone();
2565
2566        let mut permit = correlation
2567            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2568            .unwrap();
2569        permit
2570            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2571            .unwrap();
2572        permit
2573            .stage_event(
2574                &id,
2575                OastEvent::Dns(
2576                    event_key(1),
2577                    OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2578                ),
2579                OastMonotonicTime::from_millis(1_003),
2580            )
2581            .unwrap();
2582        let receipt = permit
2583            .finish(OastMonotonicTime::from_millis(1_004))
2584            .unwrap();
2585        assert_eq!(receipt.accepted_events(), 1);
2586        assert_eq!(receipt.duplicate_events(), 1);
2587        assert_eq!(correlation.unique_events(), 1);
2588        assert_eq!(correlation.accepted_events(), 1);
2589        assert_eq!(correlation.duplicate_events(), 1);
2590        assert_eq!(correlation.abandoned_polls(), 0);
2591    }
2592
2593    #[test]
2594    fn protocol_conflict_rejects_the_entire_batch_without_event_mutation() {
2595        let mut authority = authority();
2596        let (mut correlation, _) = register(&mut authority, SECRET);
2597        let id = correlation.correlation_id().clone();
2598        let assessment = correlation.assessment_id().clone();
2599        let case = correlation.verification_case().clone();
2600
2601        let mut accepted = correlation
2602            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2603            .unwrap();
2604        accepted
2605            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2606            .unwrap();
2607        accepted
2608            .finish(OastMonotonicTime::from_millis(1_003))
2609            .unwrap();
2610
2611        let mut conflicting = correlation
2612            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_004))
2613            .unwrap();
2614        conflicting
2615            .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_005))
2616            .unwrap();
2617        assert_eq!(
2618            conflicting
2619                .finish(OastMonotonicTime::from_millis(1_006))
2620                .unwrap_err(),
2621            OastError::EventKeyProtocolConflict
2622        );
2623        assert_eq!(correlation.unique_events(), 1);
2624        assert_eq!(correlation.accepted_events(), 1);
2625        assert_eq!(correlation.duplicate_events(), 0);
2626        assert_eq!(correlation.abandoned_polls(), 1);
2627    }
2628
2629    #[test]
2630    fn poll_and_unique_limits_reject_whole_batches() {
2631        let mut authority = OastCorrelationAuthority::new(
2632            epoch(EPOCH),
2633            assessment("assessment:1"),
2634            limits(2, 3, 2, 1),
2635        );
2636        let (mut correlation, _) = register(&mut authority, SECRET);
2637        let id = correlation.correlation_id().clone();
2638        let assessment = correlation.assessment_id().clone();
2639        let case = correlation.verification_case().clone();
2640
2641        let mut too_many_for_poll = correlation
2642            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2643            .unwrap();
2644        too_many_for_poll
2645            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2646            .unwrap();
2647        too_many_for_poll
2648            .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2649            .unwrap();
2650        let poll_error = OastError::PollEventLimitExceeded { maximum: 2 };
2651        assert_eq!(
2652            too_many_for_poll
2653                .stage_event(&id, dns(3), OastMonotonicTime::from_millis(1_004))
2654                .unwrap_err(),
2655            poll_error
2656        );
2657        assert_eq!(
2658            too_many_for_poll
2659                .finish(OastMonotonicTime::from_millis(1_005))
2660                .unwrap_err(),
2661            poll_error
2662        );
2663        assert_eq!(correlation.unique_events(), 0);
2664        assert_eq!(correlation.abandoned_polls(), 1);
2665
2666        let mut too_many_unique = correlation
2667            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_006))
2668            .unwrap();
2669        too_many_unique
2670            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_007))
2671            .unwrap();
2672        too_many_unique
2673            .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_008))
2674            .unwrap();
2675        assert_eq!(
2676            too_many_unique
2677                .finish(OastMonotonicTime::from_millis(1_009))
2678                .unwrap_err(),
2679            OastError::UniqueEventLimitExceeded { maximum: 1 }
2680        );
2681        assert_eq!(correlation.unique_events(), 0);
2682        assert_eq!(correlation.abandoned_polls(), 2);
2683    }
2684
2685    #[test]
2686    fn rejected_staging_poison_is_atomic_and_time_is_monotonic() {
2687        let mut correlation_authority = authority();
2688        let (mut correlation, _) = register(&mut correlation_authority, SECRET);
2689        let id = correlation.correlation_id().clone();
2690        let assessment = correlation.assessment_id().clone();
2691        let case = correlation.verification_case().clone();
2692        let mut wrong_id_bytes = SECRET;
2693        wrong_id_bytes[0] ^= 1;
2694        let mut other_authority = authority();
2695        let (_, other_receipt) = register(&mut other_authority, wrong_id_bytes);
2696
2697        let mut wrong_route = correlation
2698            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2699            .unwrap();
2700        assert_eq!(
2701            wrong_route
2702                .stage_event(
2703                    other_receipt.correlation_id(),
2704                    dns(1),
2705                    OastMonotonicTime::from_millis(1_002),
2706                )
2707                .unwrap_err(),
2708            OastError::CorrelationMismatch
2709        );
2710        assert_eq!(
2711            wrong_route
2712                .finish(OastMonotonicTime::from_millis(1_003))
2713                .unwrap_err(),
2714            OastError::CorrelationMismatch
2715        );
2716
2717        let mut regressed = correlation
2718            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_004))
2719            .unwrap();
2720        regressed
2721            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_006))
2722            .unwrap();
2723        let regression = OastError::ClockRegressed {
2724            previous: OastMonotonicTime::from_millis(1_004),
2725            current: OastMonotonicTime::from_millis(1_003),
2726        };
2727        assert_eq!(
2728            regressed
2729                .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2730                .unwrap_err(),
2731            regression
2732        );
2733        assert_eq!(
2734            regressed
2735                .finish(OastMonotonicTime::from_millis(1_007))
2736                .unwrap_err(),
2737            regression
2738        );
2739        assert_eq!(correlation.unique_events(), 0);
2740        assert_eq!(correlation.abandoned_polls(), 2);
2741    }
2742
2743    #[test]
2744    fn cancellation_and_expiry_are_sticky_typed_terminal_states() {
2745        let mut authority = authority();
2746        let (mut cancelled, _) = register(&mut authority, SECRET);
2747        let assessment = cancelled.assessment_id().clone();
2748        let case = cancelled.verification_case().clone();
2749        let receipt = cancelled
2750            .cancel(&assessment, &case, OastMonotonicTime::from_millis(1_010))
2751            .unwrap();
2752        assert_eq!(receipt.state(), OastCorrelationState::Cancelled);
2753        assert_eq!(receipt.terminal_at().as_millis(), 1_010);
2754        assert_eq!(cancelled.state(), OastCorrelationState::Cancelled);
2755        assert_eq!(cancelled.terminal_receipt(), Some(receipt));
2756        assert_eq!(
2757            cancelled
2758                .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_011),)
2759                .unwrap_err(),
2760            OastError::Cancelled
2761        );
2762
2763        let mut next_secret = SECRET;
2764        next_secret[0] ^= 1;
2765        let (mut expired, _) = register(&mut authority, next_secret);
2766        let assessment = expired.assessment_id().clone();
2767        let case = expired.verification_case().clone();
2768        let remaining = expired.remaining_polls();
2769        assert_eq!(
2770            expired
2771                .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_100),)
2772                .unwrap_err(),
2773            OastError::Expired {
2774                expires_at: OastMonotonicTime::from_millis(1_100),
2775            }
2776        );
2777        assert_eq!(expired.state(), OastCorrelationState::Expired);
2778        assert_eq!(expired.remaining_polls(), remaining);
2779        let terminal = expired.terminal_receipt().unwrap();
2780        assert_eq!(terminal.state(), OastCorrelationState::Expired);
2781        assert_eq!(terminal.terminal_at().as_millis(), 1_100);
2782    }
2783
2784    #[test]
2785    fn expiry_during_a_poll_commits_no_staged_events() {
2786        let mut authority = authority();
2787        let (mut correlation, _) = register(&mut authority, SECRET);
2788        let id = correlation.correlation_id().clone();
2789        let assessment = correlation.assessment_id().clone();
2790        let case = correlation.verification_case().clone();
2791        let mut permit = correlation
2792            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_090))
2793            .unwrap();
2794        permit
2795            .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_099))
2796            .unwrap();
2797        assert_eq!(
2798            permit
2799                .finish(OastMonotonicTime::from_millis(1_100))
2800                .unwrap_err(),
2801            OastError::Expired {
2802                expires_at: OastMonotonicTime::from_millis(1_100),
2803            }
2804        );
2805        assert_eq!(correlation.unique_events(), 0);
2806        assert_eq!(correlation.state(), OastCorrelationState::Expired);
2807        assert_eq!(correlation.abandoned_polls(), 1);
2808    }
2809
2810    #[test]
2811    fn terminal_expiry_outranks_a_poisoned_batch_at_completion() {
2812        let mut correlation_authority = authority();
2813        let (mut correlation, _) = register(&mut correlation_authority, SECRET);
2814        let assessment = correlation.assessment_id().clone();
2815        let case = correlation.verification_case().clone();
2816        let mut wrong_secret = SECRET;
2817        wrong_secret[0] ^= 1;
2818        let mut other_authority = authority();
2819        let (_, other) = register(&mut other_authority, wrong_secret);
2820
2821        let mut permit = correlation
2822            .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_090))
2823            .unwrap();
2824        assert_eq!(
2825            permit
2826                .stage_event(
2827                    other.correlation_id(),
2828                    dns(1),
2829                    OastMonotonicTime::from_millis(1_099),
2830                )
2831                .unwrap_err(),
2832            OastError::CorrelationMismatch
2833        );
2834        assert_eq!(
2835            permit
2836                .finish(OastMonotonicTime::from_millis(1_100))
2837                .unwrap_err(),
2838            OastError::Expired {
2839                expires_at: OastMonotonicTime::from_millis(1_100),
2840            }
2841        );
2842        assert_eq!(correlation.state(), OastCorrelationState::Expired);
2843        assert_eq!(correlation.unique_events(), 0);
2844        assert_eq!(correlation.abandoned_polls(), 1);
2845    }
2846}