Skip to main content

hns_gateway/
lib.rs

1//! Fail-closed transport orchestration for HNS browser resolution.
2//!
3//! The gateway consumes the typed policy plan and never accepts a
4//! caller-selected transport. Only reachability, timeout, and explicitly
5//! unsupported-path failures advance to the next candidate. Authentication or
6//! protocol failures terminate the resolution so another path cannot turn
7//! bogus evidence into a successful downgrade.
8
9#![forbid(unsafe_code)]
10#![allow(
11    clippy::doc_markdown,
12    clippy::missing_errors_doc,
13    clippy::module_name_repetitions,
14    reason = "HNS, DNS, ODoH, and P2P are protocol names"
15)]
16
17use hns_resolution_policy::{PolicySnapshot, ResolutionTransport, TransportPlan};
18use std::sync::atomic::{AtomicU64, Ordering};
19use thiserror::Error;
20
21static NEXT_GATEWAY_ID: AtomicU64 = AtomicU64::new(1);
22
23/// Default deadline for one transport attempt.
24pub const DEFAULT_ATTEMPT_TIMEOUT_SECONDS: u64 = 10;
25/// Default maximum accepted DNS response bytes.
26pub const DEFAULT_MAXIMUM_RESPONSE_BYTES: usize = 65_535;
27/// Default maximum intermediary identity bytes.
28pub const DEFAULT_MAXIMUM_IDENTITY_BYTES: usize = 256;
29
30/// Bounds applied before an adapter response can enter DNS parsing.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct GatewayLimits {
33    /// Per-attempt deadline.
34    pub attempt_timeout_seconds: u64,
35    /// Maximum response body.
36    pub maximum_response_bytes: usize,
37    /// Maximum UTF-8 bytes in each intermediary identity.
38    pub maximum_identity_bytes: usize,
39}
40
41impl Default for GatewayLimits {
42    fn default() -> Self {
43        Self {
44            attempt_timeout_seconds: DEFAULT_ATTEMPT_TIMEOUT_SECONDS,
45            maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES,
46            maximum_identity_bytes: DEFAULT_MAXIMUM_IDENTITY_BYTES,
47        }
48    }
49}
50
51impl GatewayLimits {
52    fn validate(self) -> Result<Self, GatewayError> {
53        if self.attempt_timeout_seconds == 0
54            || self.attempt_timeout_seconds > 300
55            || self.maximum_response_bytes == 0
56            || self.maximum_response_bytes > DEFAULT_MAXIMUM_RESPONSE_BYTES
57            || self.maximum_identity_bytes == 0
58            || self.maximum_identity_bytes > 4_096
59        {
60            return Err(GatewayError::InvalidLimits);
61        }
62        Ok(self)
63    }
64}
65
66/// Transport-level failure reported by one adapter.
67#[repr(u8)]
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum TransportFailure {
70    /// Destination could not be reached.
71    Unreachable = 0,
72    /// The attempt reached its finite deadline.
73    Timeout = 1,
74    /// This build/runtime does not implement the candidate.
75    Unsupported = 2,
76    /// A valid UDP response requested standards-defined TCP retry.
77    Truncated = 3,
78    /// Response framing or transport protocol was malformed.
79    Malformed = 4,
80    /// Required endpoint or intermediary authentication failed.
81    AuthenticationFailed = 5,
82    /// Runtime lifecycle cancelled the resolution.
83    Cancelled = 6,
84}
85
86impl TransportFailure {
87    const fn permits_fallback_from(self, transport: ResolutionTransport) -> bool {
88        matches!(self, Self::Unreachable | Self::Timeout | Self::Unsupported)
89            || (matches!(self, Self::Truncated)
90                && matches!(transport, ResolutionTransport::DirectAuthoritativeUdp))
91    }
92}
93
94/// Bounded identities observed on an intermediary path.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct GatewayIdentities {
97    /// P2P relay/peer identity.
98    pub peer: Option<String>,
99    /// ODoH proxy identity.
100    pub proxy: Option<String>,
101    /// ODoH authenticated-target identity.
102    pub target: Option<String>,
103}
104
105/// Adapter result for one exact gateway attempt.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub enum AttemptOutcome {
108    /// Bounded DNS response bytes and actual intermediary identities.
109    Response {
110        /// DNS response wire bytes.
111        bytes: Vec<u8>,
112        /// Identities observed by the adapter.
113        identities: GatewayIdentities,
114    },
115    /// Transport failed before a response could be admitted.
116    Failure(TransportFailure),
117}
118
119/// Opaque exact attempt token.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub struct GatewayAttempt {
122    instance_id: u64,
123    policy_generation: u64,
124    sequence: u64,
125    candidate_index: usize,
126    transport: ResolutionTransport,
127    deadline: u64,
128}
129
130impl GatewayAttempt {
131    /// Policy generation that admitted this attempt.
132    #[must_use]
133    pub const fn policy_generation(self) -> u64 {
134        self.policy_generation
135    }
136
137    /// Monotonic attempt sequence.
138    #[must_use]
139    pub const fn sequence(self) -> u64 {
140        self.sequence
141    }
142
143    /// Policy-selected transport.
144    #[must_use]
145    pub const fn transport(self) -> ResolutionTransport {
146        self.transport
147    }
148
149    /// Absolute adapter response deadline.
150    #[must_use]
151    pub const fn deadline(self) -> u64 {
152        self.deadline
153    }
154}
155
156/// Successful actual transport selection.
157#[derive(Debug, Eq, PartialEq)]
158pub struct GatewaySelection {
159    policy_generation: u64,
160    transport: ResolutionTransport,
161    response: Vec<u8>,
162    identities: GatewayIdentities,
163    direct_relay_fallback: bool,
164}
165
166impl GatewaySelection {
167    /// Policy generation of the completed plan.
168    #[must_use]
169    pub const fn policy_generation(&self) -> u64 {
170        self.policy_generation
171    }
172
173    /// Actual selected DNS transport.
174    #[must_use]
175    pub const fn transport(&self) -> ResolutionTransport {
176        self.transport
177    }
178
179    /// Bounded response bytes.
180    #[must_use]
181    pub fn response(&self) -> &[u8] {
182        &self.response
183    }
184
185    /// Actual intermediary identities.
186    #[must_use]
187    pub const fn identities(&self) -> &GatewayIdentities {
188        &self.identities
189    }
190
191    /// Whether an attempted ODoH path downgraded to direct P2P relay.
192    #[must_use]
193    pub const fn direct_relay_fallback(&self) -> bool {
194        self.direct_relay_fallback
195    }
196
197    /// Consume the one-shot selection into its admitted fields.
198    #[must_use]
199    pub fn into_parts(self) -> (u64, ResolutionTransport, Vec<u8>, GatewayIdentities, bool) {
200        (
201            self.policy_generation,
202            self.transport,
203            self.response,
204            self.identities,
205            self.direct_relay_fallback,
206        )
207    }
208}
209
210/// Result of completing one gateway attempt.
211#[derive(Debug, Eq, PartialEq)]
212pub enum GatewayStep {
213    /// A retryable transport failure left another policy candidate.
214    RetryAvailable,
215    /// One actual transport returned an admitted bounded response.
216    Selected(GatewaySelection),
217    /// Every permitted candidate failed for a retryable reason.
218    Unavailable,
219}
220
221/// One bounded direct-first gateway resolution.
222#[derive(Debug)]
223pub struct Gateway {
224    instance_id: u64,
225    limits: GatewayLimits,
226    policy: PolicySnapshot,
227    plan: TransportPlan,
228    next_candidate: usize,
229    sequence: u64,
230    active: Option<GatewayAttempt>,
231    terminal: bool,
232    odoh_attempted: bool,
233}
234
235impl Gateway {
236    /// Create from the exact current persistent policy.
237    pub fn new(policy: PolicySnapshot, limits: GatewayLimits) -> Result<Self, GatewayError> {
238        let limits = limits.validate()?;
239        let instance_id = NEXT_GATEWAY_ID
240            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
241                current.checked_add(1)
242            })
243            .map_err(|_| GatewayError::InstanceSequenceExhausted)?;
244        Ok(Self {
245            instance_id,
246            limits,
247            policy,
248            plan: TransportPlan::for_policy(policy.config()),
249            next_candidate: 0,
250            sequence: 0,
251            active: None,
252            terminal: false,
253            odoh_attempted: false,
254        })
255    }
256
257    /// Exact direct-first typed plan.
258    #[must_use]
259    pub const fn plan(&self) -> &TransportPlan {
260        &self.plan
261    }
262
263    /// Begin the next policy-selected candidate.
264    pub fn next_attempt(
265        &mut self,
266        current_policy: PolicySnapshot,
267        now: u64,
268    ) -> Result<GatewayAttempt, GatewayError> {
269        self.ensure_current_policy(current_policy)?;
270        if self.terminal {
271            return Err(GatewayError::Terminal);
272        }
273        if self.active.is_some() {
274            return Err(GatewayError::AttemptAlreadyActive);
275        }
276        let transport = self
277            .plan
278            .as_slice()
279            .get(self.next_candidate)
280            .copied()
281            .ok_or(GatewayError::NoCandidate)?;
282        let sequence = self
283            .sequence
284            .checked_add(1)
285            .ok_or(GatewayError::SequenceExhausted)?;
286        let deadline = now
287            .checked_add(self.limits.attempt_timeout_seconds)
288            .ok_or(GatewayError::TimeOverflow)?;
289        let attempt = GatewayAttempt {
290            instance_id: self.instance_id,
291            policy_generation: self.policy.generation(),
292            sequence,
293            candidate_index: self.next_candidate,
294            transport,
295            deadline,
296        };
297        self.next_candidate += 1;
298        self.sequence = sequence;
299        self.odoh_attempted |= transport == ResolutionTransport::HandshakeP2pOdoh;
300        self.active = Some(attempt);
301        Ok(attempt)
302    }
303
304    /// Complete the exact active attempt.
305    pub fn complete(
306        &mut self,
307        current_policy: PolicySnapshot,
308        attempt: GatewayAttempt,
309        outcome: AttemptOutcome,
310        now: u64,
311    ) -> Result<GatewayStep, GatewayError> {
312        self.ensure_current_policy(current_policy)?;
313        let active = self.active.ok_or(GatewayError::NoActiveAttempt)?;
314        if active != attempt {
315            return Err(GatewayError::AttemptMismatch);
316        }
317        self.active = None;
318
319        if now > attempt.deadline {
320            return self.retry_or_unavailable(TransportFailure::Timeout, attempt.transport);
321        }
322        match outcome {
323            AttemptOutcome::Failure(failure) => {
324                self.retry_or_unavailable(failure, attempt.transport)
325            }
326            AttemptOutcome::Response { bytes, identities } => {
327                if let Err(error) = self.validate_response(&bytes, &identities, attempt.transport) {
328                    self.terminal = true;
329                    return Err(error);
330                }
331                self.terminal = true;
332                Ok(GatewayStep::Selected(GatewaySelection {
333                    policy_generation: self.policy.generation(),
334                    transport: attempt.transport,
335                    response: bytes,
336                    identities,
337                    direct_relay_fallback: attempt.transport
338                        == ResolutionTransport::HandshakeP2pDnsRelay
339                        && self.odoh_attempted,
340                }))
341            }
342        }
343    }
344
345    /// Revoke the in-flight plan after any policy-generation change.
346    pub fn revoke(&mut self) {
347        self.active = None;
348        self.terminal = true;
349    }
350
351    fn retry_or_unavailable(
352        &mut self,
353        failure: TransportFailure,
354        transport: ResolutionTransport,
355    ) -> Result<GatewayStep, GatewayError> {
356        if !failure.permits_fallback_from(transport) {
357            self.terminal = true;
358            return Err(GatewayError::TerminalTransportFailure(failure));
359        }
360        if self.next_candidate < self.plan.as_slice().len() {
361            Ok(GatewayStep::RetryAvailable)
362        } else {
363            self.terminal = true;
364            Ok(GatewayStep::Unavailable)
365        }
366    }
367
368    fn ensure_current_policy(&mut self, current: PolicySnapshot) -> Result<(), GatewayError> {
369        if current.generation() != self.policy.generation()
370            || current.config() != self.policy.config()
371        {
372            self.revoke();
373            return Err(GatewayError::StalePolicy);
374        }
375        Ok(())
376    }
377
378    fn validate_response(
379        &self,
380        bytes: &[u8],
381        identities: &GatewayIdentities,
382        transport: ResolutionTransport,
383    ) -> Result<(), GatewayError> {
384        if bytes.is_empty() || bytes.len() > self.limits.maximum_response_bytes {
385            return Err(GatewayError::InvalidResponseSize);
386        }
387        for identity in [
388            identities.peer.as_deref(),
389            identities.proxy.as_deref(),
390            identities.target.as_deref(),
391        ]
392        .into_iter()
393        .flatten()
394        {
395            if identity.is_empty() || identity.len() > self.limits.maximum_identity_bytes {
396                return Err(GatewayError::InvalidIdentity);
397            }
398        }
399        match transport {
400            ResolutionTransport::HandshakeP2pOdoh => {
401                let proxy = identities
402                    .proxy
403                    .as_deref()
404                    .ok_or(GatewayError::MissingIdentity)?;
405                let target = identities
406                    .target
407                    .as_deref()
408                    .ok_or(GatewayError::MissingIdentity)?;
409                if identities.peer.is_some() || proxy == target {
410                    return Err(GatewayError::InvalidIdentityTopology);
411                }
412            }
413            ResolutionTransport::HandshakeP2pDnsRelay => {
414                if identities.peer.is_none()
415                    || identities.proxy.is_some()
416                    || identities.target.is_some()
417                {
418                    return Err(GatewayError::InvalidIdentityTopology);
419                }
420            }
421            ResolutionTransport::DirectAuthoritativeUdp
422            | ResolutionTransport::DirectAuthoritativeTcp
423            | ResolutionTransport::AuthenticatedAuthoritativeDoh
424            | ResolutionTransport::UserConfiguredRecursiveHnsDoh => {
425                if identities != &GatewayIdentities::default() {
426                    return Err(GatewayError::InvalidIdentityTopology);
427                }
428            }
429            ResolutionTransport::Unavailable
430            | ResolutionTransport::ValidatingIcannDoh
431            | ResolutionTransport::LocalHnsProof => {
432                return Err(GatewayError::UnavailableTransport);
433            }
434        }
435        Ok(())
436    }
437}
438
439/// Gateway policy, attempt, bound, or terminal failure.
440#[derive(Debug, Error)]
441#[non_exhaustive]
442pub enum GatewayError {
443    /// Configured bounds are zero or excessive.
444    #[error("invalid HNS gateway limits")]
445    InvalidLimits,
446    /// The gateway's policy generation or effective policy changed.
447    #[error("HNS gateway policy is stale")]
448    StalePolicy,
449    /// A candidate is already awaiting completion.
450    #[error("HNS gateway attempt is already active")]
451    AttemptAlreadyActive,
452    /// No attempt is active.
453    #[error("HNS gateway has no active attempt")]
454    NoActiveAttempt,
455    /// Completion token differs from the exact active attempt.
456    #[error("HNS gateway completion does not match the active attempt")]
457    AttemptMismatch,
458    /// The policy plan has no remaining candidate.
459    #[error("HNS gateway has no remaining transport candidate")]
460    NoCandidate,
461    /// A selected or failed plan is terminal.
462    #[error("HNS gateway is terminal")]
463    Terminal,
464    /// Attempt counter cannot advance.
465    #[error("HNS gateway attempt sequence exhausted")]
466    SequenceExhausted,
467    /// Process-local gateway instance counter cannot advance.
468    #[error("HNS gateway instance sequence exhausted")]
469    InstanceSequenceExhausted,
470    /// Deadline arithmetic overflowed.
471    #[error("HNS gateway deadline overflow")]
472    TimeOverflow,
473    /// A non-retryable transport failure terminated the resolution.
474    #[error("HNS gateway transport failed closed: {0:?}")]
475    TerminalTransportFailure(TransportFailure),
476    /// Response is empty or exceeds the DNS wire bound.
477    #[error("HNS gateway response size is invalid")]
478    InvalidResponseSize,
479    /// Required intermediary identity is absent.
480    #[error("HNS gateway intermediary identity is missing")]
481    MissingIdentity,
482    /// Intermediary identity is empty or excessive.
483    #[error("HNS gateway intermediary identity is invalid")]
484    InvalidIdentity,
485    /// Identities do not match the selected transport.
486    #[error("HNS gateway intermediary topology is invalid")]
487    InvalidIdentityTopology,
488    /// Unavailable is a status, never an attempt transport.
489    #[error("unavailable cannot carry a gateway response")]
490    UnavailableTransport,
491}
492
493#[cfg(test)]
494#[allow(
495    clippy::unwrap_used,
496    reason = "tests fail immediately on invalid deterministic gateway fixtures"
497)]
498mod tests {
499    use hns_resolution_policy::{ObliviousDnsPolicy, PolicyConfig, ProviderPolicy, WireProfile};
500
501    use super::*;
502
503    fn policy() -> PolicySnapshot {
504        PolicySnapshot::default()
505    }
506
507    fn failure_then_next(
508        gateway: &mut Gateway,
509        snapshot: PolicySnapshot,
510        now: u64,
511    ) -> GatewayAttempt {
512        let attempt = gateway.next_attempt(snapshot, now).unwrap();
513        assert_eq!(
514            gateway
515                .complete(
516                    snapshot,
517                    attempt,
518                    AttemptOutcome::Failure(TransportFailure::Unreachable),
519                    now
520                )
521                .unwrap(),
522            GatewayStep::RetryAvailable
523        );
524        attempt
525    }
526
527    #[test]
528    fn follows_direct_first_plan_and_derives_relay_downgrade() {
529        let snapshot = policy();
530        let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
531        assert_eq!(
532            gateway.plan().as_slice(),
533            [
534                ResolutionTransport::DirectAuthoritativeUdp,
535                ResolutionTransport::DirectAuthoritativeTcp,
536                ResolutionTransport::AuthenticatedAuthoritativeDoh,
537                ResolutionTransport::HandshakeP2pOdoh,
538                ResolutionTransport::HandshakeP2pDnsRelay,
539            ]
540        );
541        for expected in [
542            ResolutionTransport::DirectAuthoritativeUdp,
543            ResolutionTransport::DirectAuthoritativeTcp,
544            ResolutionTransport::AuthenticatedAuthoritativeDoh,
545            ResolutionTransport::HandshakeP2pOdoh,
546        ] {
547            assert_eq!(
548                failure_then_next(&mut gateway, snapshot, 100).transport(),
549                expected
550            );
551        }
552        let relay = gateway.next_attempt(snapshot, 100).unwrap();
553        assert_eq!(relay.transport(), ResolutionTransport::HandshakeP2pDnsRelay);
554        let selected = gateway
555            .complete(
556                snapshot,
557                relay,
558                AttemptOutcome::Response {
559                    bytes: vec![1, 2, 3],
560                    identities: GatewayIdentities {
561                        peer: Some("relay-peer".to_owned()),
562                        ..GatewayIdentities::default()
563                    },
564                },
565                100,
566            )
567            .unwrap();
568        let selected = match selected {
569            GatewayStep::Selected(selected) => Some(selected),
570            GatewayStep::RetryAvailable | GatewayStep::Unavailable => None,
571        }
572        .unwrap();
573        assert!(selected.direct_relay_fallback());
574        assert_eq!(
575            selected.transport(),
576            ResolutionTransport::HandshakeP2pDnsRelay
577        );
578    }
579
580    #[test]
581    fn malformed_and_authentication_failures_are_terminal() {
582        for failure in [
583            TransportFailure::Malformed,
584            TransportFailure::AuthenticationFailed,
585            TransportFailure::Cancelled,
586        ] {
587            let snapshot = policy();
588            let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
589            let attempt = gateway.next_attempt(snapshot, 100).unwrap();
590            assert!(matches!(
591                gateway.complete(
592                    snapshot,
593                    attempt,
594                    AttemptOutcome::Failure(failure),
595                    100
596                ),
597                Err(GatewayError::TerminalTransportFailure(actual)) if actual == failure
598            ));
599            assert!(matches!(
600                gateway.next_attempt(snapshot, 100),
601                Err(GatewayError::Terminal)
602            ));
603        }
604    }
605
606    #[test]
607    fn enforces_response_bounds_and_transport_identity_topology() {
608        let mut config = PolicyConfig {
609            authenticated_authoritative_doh: false,
610            oblivious_dns: ObliviousDnsPolicy::Required,
611            providers: ProviderPolicy::default(),
612            wire_profile: WireProfile::DenuoV1,
613            ..PolicyConfig::default()
614        };
615        let snapshot = PolicySnapshot::new(9, config).unwrap();
616        let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
617        failure_then_next(&mut gateway, snapshot, 100);
618        failure_then_next(&mut gateway, snapshot, 100);
619        let odoh = gateway.next_attempt(snapshot, 100).unwrap();
620        assert!(matches!(
621            gateway.complete(
622                snapshot,
623                odoh,
624                AttemptOutcome::Response {
625                    bytes: vec![1],
626                    identities: GatewayIdentities {
627                        proxy: Some("same".to_owned()),
628                        target: Some("same".to_owned()),
629                        ..GatewayIdentities::default()
630                    },
631                },
632                100
633            ),
634            Err(GatewayError::InvalidIdentityTopology)
635        ));
636        assert!(matches!(
637            gateway.next_attempt(snapshot, 100),
638            Err(GatewayError::Terminal)
639        ));
640
641        config.oblivious_dns = ObliviousDnsPolicy::Disabled;
642        let direct = PolicySnapshot::new(10, config).unwrap();
643        let mut gateway = Gateway::new(
644            direct,
645            GatewayLimits {
646                maximum_response_bytes: 2,
647                ..GatewayLimits::default()
648            },
649        )
650        .unwrap();
651        let attempt = gateway.next_attempt(direct, 100).unwrap();
652        assert!(matches!(
653            gateway.complete(
654                direct,
655                attempt,
656                AttemptOutcome::Response {
657                    bytes: vec![1, 2, 3],
658                    identities: GatewayIdentities::default(),
659                },
660                100
661            ),
662            Err(GatewayError::InvalidResponseSize)
663        ));
664    }
665
666    #[test]
667    fn rejects_stale_policy_mismatched_tokens_and_late_responses() {
668        let snapshot = policy();
669        let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
670        let attempt = gateway.next_attempt(snapshot, 100).unwrap();
671        assert!(matches!(
672            gateway.next_attempt(snapshot, 100),
673            Err(GatewayError::AttemptAlreadyActive)
674        ));
675
676        let mut other = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
677        let foreign = other.next_attempt(snapshot, 100).unwrap();
678        assert!(matches!(
679            gateway.complete(
680                snapshot,
681                foreign,
682                AttemptOutcome::Failure(TransportFailure::Timeout),
683                100
684            ),
685            Err(GatewayError::AttemptMismatch)
686        ));
687        assert_eq!(
688            gateway
689                .complete(
690                    snapshot,
691                    attempt,
692                    AttemptOutcome::Response {
693                        bytes: vec![1],
694                        identities: GatewayIdentities::default(),
695                    },
696                    111
697                )
698                .unwrap(),
699            GatewayStep::RetryAvailable
700        );
701
702        let changed = PolicySnapshot::new(snapshot.generation() + 1, snapshot.config()).unwrap();
703        assert!(matches!(
704            gateway.next_attempt(changed, 112),
705            Err(GatewayError::StalePolicy)
706        ));
707        assert!(matches!(
708            gateway.next_attempt(snapshot, 112),
709            Err(GatewayError::Terminal)
710        ));
711    }
712
713    #[test]
714    fn valid_udp_truncation_advances_only_to_direct_tcp() {
715        let snapshot = policy();
716        let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
717        let udp = gateway.next_attempt(snapshot, 100).unwrap();
718        assert_eq!(udp.transport(), ResolutionTransport::DirectAuthoritativeUdp);
719        assert_eq!(
720            gateway
721                .complete(
722                    snapshot,
723                    udp,
724                    AttemptOutcome::Failure(TransportFailure::Truncated),
725                    100
726                )
727                .unwrap(),
728            GatewayStep::RetryAvailable
729        );
730        let tcp = gateway.next_attempt(snapshot, 100).unwrap();
731        assert_eq!(tcp.transport(), ResolutionTransport::DirectAuthoritativeTcp);
732        let selected = gateway
733            .complete(
734                snapshot,
735                tcp,
736                AttemptOutcome::Response {
737                    bytes: vec![1],
738                    identities: GatewayIdentities::default(),
739                },
740                100,
741            )
742            .unwrap();
743        assert!(matches!(
744            selected,
745            GatewayStep::Selected(selection)
746                if selection.transport() == ResolutionTransport::DirectAuthoritativeTcp
747                    && !selection.direct_relay_fallback()
748        ));
749    }
750
751    #[test]
752    fn recursive_hns_doh_is_terminal_and_requires_policy_consent() {
753        let config = PolicyConfig {
754            user_configured_recursive_hns_doh: true,
755            ..PolicyConfig::default()
756        };
757        let snapshot = PolicySnapshot::new(2, config).unwrap();
758        let gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
759
760        assert_eq!(
761            gateway.plan().as_slice().last(),
762            Some(&ResolutionTransport::UserConfiguredRecursiveHnsDoh)
763        );
764        assert!(
765            gateway
766                .validate_response(
767                    &[1],
768                    &GatewayIdentities::default(),
769                    ResolutionTransport::UserConfiguredRecursiveHnsDoh,
770                )
771                .is_ok()
772        );
773    }
774
775    #[test]
776    fn unsupported_final_candidate_reports_unavailable() {
777        let config = PolicyConfig {
778            authenticated_authoritative_doh: false,
779            oblivious_dns: ObliviousDnsPolicy::Disabled,
780            dns_relay_requester: hns_resolution_policy::DnsRelayRequesterPolicy::Disabled,
781            ..PolicyConfig::default()
782        };
783        let snapshot = PolicySnapshot::new(3, config).unwrap();
784        let mut gateway = Gateway::new(snapshot, GatewayLimits::default()).unwrap();
785        let first = gateway.next_attempt(snapshot, 100).unwrap();
786        assert_eq!(
787            gateway
788                .complete(
789                    snapshot,
790                    first,
791                    AttemptOutcome::Failure(TransportFailure::Unsupported),
792                    100
793                )
794                .unwrap(),
795            GatewayStep::RetryAvailable
796        );
797        let second = gateway.next_attempt(snapshot, 100).unwrap();
798        assert_eq!(
799            gateway
800                .complete(
801                    snapshot,
802                    second,
803                    AttemptOutcome::Failure(TransportFailure::Unsupported),
804                    100
805                )
806                .unwrap(),
807            GatewayStep::Unavailable
808        );
809    }
810}