Skip to main content

snap_control/pg_wap/
paths.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Path combination.
16//!
17//! [`PathManager`] combines the public segments of the [`SegmentManager`] with the segments granted
18//! by the [`GrantManager`] into the single best path, and reports which segments went into it so
19//! callers can re-path and observe grant expiry.
20
21use std::{
22    net::IpAddr,
23    sync::Arc,
24    time::{Duration, SystemTime, UNIX_EPOCH},
25};
26
27use anyhow::{Context, bail};
28use sciparse::{
29    identifier::isd_asn::IsdAsn,
30    path::{
31        ScionPath,
32        combinator::graph::{InputSegment, MultiGraph, number_of_hops},
33    },
34    reexport::tinyvec::ArrayVec,
35    segment::{SegmentFp, SignedPathSegment},
36};
37
38use crate::pg_wap::{
39    grants::{DstGrant, GrantManager, GrantedSegmentId, SegmentGrantGuard, TargetGrantGuard},
40    segments::{PairGuard, SegmentManager, SegmentStoreId, SegmentsIter},
41    sni::{CustomerDomainRef, WapSNI},
42};
43
44/// Combines public and granted segments into paths.
45///
46/// Cheap to clone; all clones share the same state.
47#[derive(Clone)]
48pub struct PathManager(Arc<PathManagerInner>);
49
50/// The state shared by all clones of a [`PathManager`].
51struct PathManagerInner {
52    segments: SegmentManager,
53    grant: GrantManager,
54}
55
56impl PathManager {
57    /// Creates a path manager on top of the given segment sources.
58    pub fn new(segments: SegmentManager, grant: GrantManager) -> Self {
59        Self(Arc::new(PathManagerInner { segments, grant }))
60    }
61
62    /// Returns the best path from the given source ISD-ASN to the destination ISD-ASN.
63    ///
64    /// This combines public segments in the segment manager and the granted segments for the
65    /// given client IP and destination SNI.
66    ///
67    /// Returns the best path and the segments that make up the path.
68    /// Returns None if no path could be found.
69    /// Returns an error if something went wrong while fetching segments or computing the path.
70    ///
71    /// ### Parameters
72    /// - `client_ip`: The IP address of the client requesting the path.
73    /// - `dst_sni`: The destination SNI for which the path is requested.
74    /// - `src`: The source ISD-ASN.
75    /// - `dst`: The destination ISD-ASN.
76    pub async fn best_path(
77        &self,
78        client_ip: IpAddr,
79        dst_sni: &WapSNI,
80        src: IsdAsn,
81        dst: IsdAsn,
82        now: SystemTime,
83    ) -> Result<Option<UsedPath>, BestPathError> {
84        if src.is_wildcard() || dst.is_wildcard() {
85            return Err(BestPathError::WildcardIA);
86        }
87
88        // Check if the client is authorized for the destination SNI and get the granted segments.
89        let auth_segments = self
90            .0
91            .grant
92            .dst_grant(client_ip, dst_sni.customer_domain(), now)
93            .ok_or(BestPathError::UnauthorizedDestination)?;
94
95        // Check if the destination is in the same AS
96        if src == dst {
97            return Ok(Some(UsedPath {
98                path: ScionPath::local(src).expect("checked above that src is not a wildcard"),
99                src,
100                dst,
101                segments: ArrayVec::new(),
102            }));
103        }
104
105        // Get all public segments for the (src, dst) pair. This holds a lock on the store's entry
106        // bucket, so nothing below may await on the segment manager again.
107        // XXX(performance): This needs to be optimized to avoid blocking when we scale up.
108        let store_segments = self
109            .0
110            .segments
111            .segments(src, dst, now)
112            .await
113            .context("Failed to get segments for the given (src, dst) pair")?;
114
115        let inputs = store_segments
116            .iter_core_segments()
117            .map(InputSegment::new_core)
118            .chain(
119                store_segments
120                    .iter_non_core_segments()
121                    .map(InputSegment::new_non_core),
122            )
123            .chain(
124                auth_segments
125                    .iter_core_segments()
126                    .map(InputSegment::new_core),
127            )
128            .chain(
129                auth_segments
130                    .iter_non_core_segments()
131                    .map(InputSegment::new_non_core),
132            );
133
134        // TODO: This will have to start taking path issues into account, either in the
135        // scoring function, or when selecting the best solution from the graph.
136        let Some((path, fps)) = best_solution(src, dst, inputs, |_| true) else {
137            return Ok(None);
138        };
139
140        // For each fingerprint in the path, find out which source it came
141        let mut segments = ArrayVec::new();
142        for fp in fps {
143            segments.push(
144                segment_source(fp, src, dst, &store_segments, &auth_segments)
145                    .ok_or(BestPathError::UnauthorizedSegments)?,
146            );
147        }
148
149        Ok(Some(UsedPath {
150            path,
151            src,
152            dst,
153            segments,
154        }))
155    }
156
157    /// Rebuilds `used` from the current copies of the exact segments it was built from.
158    ///
159    /// Allows the caller to pick up a later expiration of the underlying segments.
160    ///
161    /// Returns `Ok(UsedPath)` if the path could be rebuilt, or `Err` if it could not be rebuilt.
162    pub async fn refresh_path(&self, used: &UsedPath, now: SystemTime) -> anyhow::Result<UsedPath> {
163        if used.segments.is_empty() {
164            // An AS local path is not built from segments and never expires.
165            return Ok(used.clone());
166        }
167
168        let mut segments = Vec::with_capacity(used.segments.len());
169        for id in used.segments.iter() {
170            let Some(segment) = self.resolve(id, now).await else {
171                bail!("Segment {:?} is no longer available", id);
172            };
173            segments.push((id.is_core(), segment));
174        }
175
176        let inputs = segments.iter().map(|(is_core, segment)| {
177            if *is_core {
178                InputSegment::new_core(segment)
179            } else {
180                InputSegment::new_non_core(segment)
181            }
182        });
183
184        // Only the segments of `used` went into the graph, so every solution uses a subset of
185        // them. Require the exact same segments in the same order, so the refreshed path is
186        // interchangeable with the one it replaces.
187        let expected: Vec<SegmentFp> = used.segments.iter().map(SegmentSourceId::fp).collect();
188        let (path, _segment_fps) =
189            best_solution(used.src, used.dst, inputs, |fps| fps == &expected[..])
190                .context("Refreshed segments did not combine into a path")?;
191
192        Ok(UsedPath {
193            path,
194            src: used.src,
195            dst: used.dst,
196            segments: used.segments.clone(),
197        })
198    }
199
200    /// Keeps the public segments `used` was built from available for as long as the returned
201    /// guard is alive, so [`Self::refresh_path`] can still resolve them.
202    ///
203    /// Anything that keeps a [`UsedPath`] around for longer than the segment manager's
204    /// `idle_eviction_time` has to hold on to the guard, otherwise the pair backing the path is
205    /// evicted while the path is still in use.
206    ///
207    /// Granted segments need no guard: they are held in place by their grant, not by use.
208    pub async fn hold_segments(
209        &self,
210        used: &UsedPath,
211        now: SystemTime,
212    ) -> anyhow::Result<PathSegmentsGuard> {
213        let mut guards = Vec::new();
214
215        for id in used.segments.iter() {
216            let SegmentSourceId::Store(id) = id else {
217                continue;
218            };
219
220            guards.push(
221                self.0
222                    .segments
223                    .hold_pair(id.src(), id.dst(), now)
224                    .await
225                    .with_context(|| {
226                        format!(
227                            "Failed to hold the segments of ({}, {})",
228                            id.src(),
229                            id.dst()
230                        )
231                    })?,
232            );
233        }
234
235        Ok(PathSegmentsGuard { _guards: guards })
236    }
237
238    /// Watches the grants that let `client_ip` use `used` towards `dst_sni`.
239    ///
240    /// The returned watch resolves once any of the grants that authorize the path expires.
241    /// It also guards the target and the private segments of the path from early eviction.
242    ///
243    /// Returns an error if the client is not authorized for the path in the first place.
244    pub fn watch_grants(
245        &self,
246        client_ip: IpAddr,
247        customer_domain: CustomerDomainRef<'_>,
248        used: &UsedPath,
249        now: SystemTime,
250    ) -> anyhow::Result<GrantWatch> {
251        let target = self
252            .0
253            .grant
254            .watch_grant(client_ip, customer_domain, now)
255            .with_context(|| format!("No live grant of {client_ip} for {customer_domain}"))?;
256
257        let mut segments = Vec::new();
258
259        for id in used.segments.iter() {
260            let SegmentSourceId::Auth(id) = id else {
261                continue;
262            };
263
264            segments.push(
265                self.0
266                    .grant
267                    .watch_segment_grant(client_ip, customer_domain, id, now)
268                    .with_context(|| {
269                        format!(
270                            "No live grant of {client_ip} for segment {} of the path",
271                            id.fp()
272                        )
273                    })?,
274            );
275        }
276
277        Ok(GrantWatch { target, segments })
278    }
279
280    /// Looks a segment up in the source it came from.
281    async fn resolve(&self, id: &SegmentSourceId, now: SystemTime) -> Option<SignedPathSegment> {
282        match id {
283            SegmentSourceId::Store(id) => self.0.segments.segment(*id, now).await,
284            SegmentSourceId::Auth(id) => self.0.grant.segment(id, now),
285        }
286    }
287}
288
289/// Errors that can occur while computing the best path.
290#[derive(thiserror::Error, Debug)]
291pub enum BestPathError {
292    /// The client holds no valid grant for the destination.
293    #[error("the client is not authorized to access the given destination")]
294    UnauthorizedDestination,
295    /// One hop of the path comes from a segment that is neither public nor granted to the client.
296    #[error("the client is not authorized to use one or more of the given segments")]
297    UnauthorizedSegments,
298    /// The source or the destination ISD-AS is a wildcard.
299    #[error("src or dst IA contained a wildcard, this is not allowed")]
300    WildcardIA,
301    /// The path could not be built for any other reason.
302    #[error("failed to compute the best path: {0}")]
303    Other(#[from] anyhow::Error),
304}
305
306/// Combines `inputs` into the cheapest loop free path from `src` to `dst` that `accept`ed.
307///
308/// Solutions are returned by the graph in ascending cost, so the first match is the best one.
309fn best_solution<'segments>(
310    src: IsdAsn,
311    dst: IsdAsn,
312    inputs: impl Iterator<Item = InputSegment<'segments, sciparse::segment::SignedAsEntry>>,
313    accept: impl Fn(&[SegmentFp]) -> bool,
314) -> Option<(ScionPath, ArrayVec<[SegmentFp; 3]>)> {
315    // Build a graph of all segments and find all paths from src to dst, using the number of
316    // hops as the cost function.
317    let mut graph = MultiGraph::new(number_of_hops);
318    graph.add_segments(inputs);
319
320    graph
321        .get_paths(src, dst)
322        .iter()
323        .filter_map(|solution| solution.path().ok().flatten())
324        .find(|(_, fps)| accept(fps))
325}
326
327/// Finds which of the two sources the segment `fp` refers to was taken from.
328///
329/// Returns `None` if neither source holds it.
330fn segment_source(
331    fp: SegmentFp,
332    src: IsdAsn,
333    dst: IsdAsn,
334    store: &SegmentsIter<'_>,
335    granted: &DstGrant,
336) -> Option<SegmentSourceId> {
337    // XXX: This is awful, we should ideally be able to tag segments with their source in the graph
338    // directly.
339    if store.has_core_segment(fp) {
340        return Some(SegmentSourceId::Store(SegmentStoreId::Core {
341            src,
342            dst,
343            fp,
344        }));
345    }
346    if store.has_non_core_segment(fp) {
347        return Some(SegmentSourceId::Store(SegmentStoreId::NonCore {
348            src,
349            dst,
350            fp,
351        }));
352    }
353    if granted.has_core_segment(fp) {
354        return Some(SegmentSourceId::Auth(GrantedSegmentId::Core(fp)));
355    }
356    if granted.has_non_core_segment(fp) {
357        return Some(SegmentSourceId::Auth(GrantedSegmentId::NonCore(fp)));
358    }
359
360    None
361}
362
363/// Where a segment used in a path came from.
364///
365/// Lets a consumer of a path go back to the segment it was built from, either to pick up a
366/// fresher copy of it, or to observe the expiry of the grant that made it available.
367#[derive(Debug, Clone, PartialEq, Eq, Hash)]
368pub enum SegmentSourceId {
369    /// A public segment from the [`SegmentManager`].
370    Store(SegmentStoreId),
371    /// A private segment granted by the [`GrantManager`].
372    Auth(GrantedSegmentId),
373}
374
375impl SegmentSourceId {
376    /// The fingerprint of the segment.
377    pub fn fp(&self) -> SegmentFp {
378        match self {
379            Self::Store(id) => id.fp(),
380            Self::Auth(id) => id.fp(),
381        }
382    }
383
384    /// Returns true if this identifies a core segment.
385    pub fn is_core(&self) -> bool {
386        match self {
387            Self::Store(id) => id.is_core(),
388            Self::Auth(id) => id.is_core(),
389        }
390    }
391}
392
393// Required by `tinyvec::ArrayVec`, which needs a `Default` item to back its inline storage.
394impl Default for SegmentSourceId {
395    fn default() -> Self {
396        Self::Store(SegmentStoreId::Core {
397            src: IsdAsn(0),
398            dst: IsdAsn(0),
399            fp: SegmentFp::default(),
400        })
401    }
402}
403
404/// Watches one client's authorization to use a path.
405///
406/// Guards the target and the private segments of the path from eviction.
407pub struct GrantWatch {
408    /// The client's grant on the target the path leads to.
409    target: TargetGrantGuard,
410    /// The client's grants on the private segments of the path.
411    segments: Vec<SegmentGrantGuard>,
412}
413
414impl GrantWatch {
415    /// Resolves once the client loses any of the grants that authorize the path.
416    ///
417    /// Cancel safe: dropping this future and awaiting it again `still observes an expiry that
418    /// happened in between.
419    pub async fn expired(&self) {
420        let expiries = std::iter::once(self.target.expired())
421            .chain(self.segments.iter().map(SegmentGrantGuard::expired))
422            .map(|token| Box::pin(token.cancelled()));
423
424        futures::future::select_all(expiries).await;
425    }
426}
427
428/// Keeps the public segments a [`UsedPath`] was built from available.
429///
430/// Handed out by [`PathManager::hold_segments`]; a path can be built from at most three segments,
431/// so this holds at most three guards.
432pub struct PathSegmentsGuard {
433    _guards: Vec<PairGuard>,
434}
435
436/// A path together with the segments it was combined from.
437///
438/// A path can be built from at most three segments (up, core, down).
439#[derive(Debug, Clone)]
440pub struct UsedPath {
441    /// The combined path.
442    pub path: ScionPath,
443    /// The source the path was combined for.
444    pub src: IsdAsn,
445    /// The destination the path was combined for.
446    pub dst: IsdAsn,
447    /// The segments the path was combined from, in path order.
448    pub segments: ArrayVec<[SegmentSourceId; 3]>,
449}
450
451impl UsedPath {
452    /// The expiration of the underlying dataplane path.
453    ///
454    /// `None` for paths that do not carry an expiration, e.g. AS local paths.
455    pub fn expiration(&self) -> Option<SystemTime> {
456        self.path
457            .expiration()
458            .map(|secs| UNIX_EPOCH + Duration::from_secs(u64::from(secs)))
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use futures::FutureExt;
465    use sciparse::segment::Segments;
466
467    use super::*;
468    use crate::pg_wap::{
469        crpc::model::AuthSegments,
470        test_util::{
471            Fixture, IDLE_EVICTION_TIME, MAX_FETCH_INTERVAL, MockFetcher, at, client_ip, core_ia,
472            core_segment, core_store_id, down_segment, granted_core_id, granted_id, leaf_ia,
473            non_core_store_id, other_leaf_ia, sni, store_id, up_segment,
474        },
475    };
476
477    #[tokio::test]
478    async fn best_path_combines_granted_segments_and_reports_them() {
479        let up = up_segment(0);
480        let fixture = Fixture::new(MockFetcher::empty(), Duration::from_secs(100));
481
482        assert!(
483            fixture
484                .paths
485                .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
486                .await
487                .is_err(),
488            "a client without a grant for the target gets no path at all"
489        );
490
491        // Granting the target without granting any segment leaves nothing to combine.
492        fixture.grant_non_core(Vec::new(), at(0));
493        assert!(
494            fixture
495                .paths
496                .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
497                .await
498                .expect("path combination succeeds")
499                .is_none(),
500            "without any segments there is no path"
501        );
502
503        fixture.grant_non_core(vec![up.clone()], at(0));
504
505        let used = fixture
506            .paths
507            .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
508            .await
509            .expect("path combination succeeds")
510            .expect("the granted segment yields a path");
511
512        assert_eq!(
513            used.segments.as_slice(),
514            [SegmentSourceId::Auth(granted_id(&up))],
515            "the path must report the grant it depends on"
516        );
517    }
518
519    #[tokio::test]
520    async fn best_path_reports_public_segments_as_public() {
521        let up = up_segment(0);
522        let fixture = Fixture::new(
523            MockFetcher::with_up_segments(vec![up.clone()]),
524            Duration::from_secs(100),
525        );
526        // The same segment is also granted; the public copy must win, so the path does not
527        // needlessly depend on a grant.
528        fixture.grant_non_core(vec![up.clone()], at(0));
529
530        let used = fixture
531            .paths
532            .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
533            .await
534            .expect("path combination succeeds")
535            .expect("the public segment yields a path");
536
537        assert_eq!(
538            used.segments.as_slice(),
539            [SegmentSourceId::Store(store_id(&up))]
540        );
541    }
542
543    /// A path over all three segment types, with the core segment public and the up and down
544    /// segments granted, so both sources contribute to one path.
545    ///
546    /// Returns the fixture and the path from [`leaf_ia`] to [`other_leaf_ia`].
547    async fn three_segment_fixture() -> (Fixture, UsedPath) {
548        let fixture = Fixture::new(
549            MockFetcher::new(Segments {
550                core_segments: vec![core_segment(0)],
551                ..Segments::default()
552            }),
553            Duration::from_secs(10 * 24 * 3600),
554        );
555        fixture.grant_for(
556            client_ip(),
557            sni().customer_domain(),
558            AuthSegments {
559                up_segments: vec![up_segment(0)],
560                down_segments: vec![down_segment(0)],
561                ..AuthSegments::default()
562            },
563            at(0),
564        );
565
566        let used = fixture
567            .paths
568            .best_path(client_ip(), &sni(), leaf_ia(), other_leaf_ia(), at(0))
569            .await
570            .expect("path combination succeeds")
571            .expect("up, core and down combine into a path");
572
573        (fixture, used)
574    }
575
576    #[tokio::test]
577    async fn best_path_combines_up_core_and_down_and_tags_every_source() {
578        let (_fixture, used) = three_segment_fixture().await;
579
580        assert_eq!(
581            used.segments.as_slice(),
582            [
583                SegmentSourceId::Auth(granted_id(&up_segment(0))),
584                SegmentSourceId::Store(core_store_id(&core_segment(0), leaf_ia(), other_leaf_ia())),
585                SegmentSourceId::Auth(granted_id(&down_segment(0))),
586            ],
587            "the path reports its three segments in path order, each tagged with its source"
588        );
589    }
590
591    #[tokio::test]
592    async fn best_path_combines_a_granted_core_segment_with_public_up_and_down() {
593        let fixture = Fixture::new(
594            MockFetcher::new(Segments {
595                up_segments: vec![up_segment(0)],
596                down_segments: vec![down_segment(0)],
597                ..Segments::default()
598            }),
599            Duration::from_secs(100),
600        );
601        // The core hop between the two core ASes is the private part of this path.
602        fixture.grant_for(
603            client_ip(),
604            sni().customer_domain(),
605            AuthSegments {
606                core_segments: vec![core_segment(0)],
607                ..AuthSegments::default()
608            },
609            at(0),
610        );
611
612        let used = fixture
613            .paths
614            .best_path(client_ip(), &sni(), leaf_ia(), other_leaf_ia(), at(0))
615            .await
616            .expect("path combination succeeds")
617            .expect("the granted core segment closes the gap between the public ones");
618
619        assert_eq!(
620            used.segments.as_slice(),
621            [
622                SegmentSourceId::Store(non_core_store_id(
623                    &up_segment(0),
624                    leaf_ia(),
625                    other_leaf_ia()
626                )),
627                SegmentSourceId::Auth(granted_core_id(&core_segment(0))),
628                SegmentSourceId::Store(non_core_store_id(
629                    &down_segment(0),
630                    leaf_ia(),
631                    other_leaf_ia()
632                )),
633            ],
634            "a granted core segment is reported as granted, not as public"
635        );
636
637        // The client keeps the path only while its grant on that core segment holds.
638        let watch = fixture
639            .paths
640            .watch_grants(client_ip(), sni().customer_domain(), &used, at(0))
641            .expect("the client is authorized for the path");
642        fixture.grant.clean(at(101));
643        assert!(watch.expired().now_or_never().is_some());
644    }
645
646    #[tokio::test]
647    async fn refresh_path_keeps_a_three_segment_path_intact() {
648        let (fixture, used) = three_segment_fixture().await;
649
650        // The uplink's guard is what keeps the public pair from being evicted while we wait for
651        // its refresh to come due.
652        let _guard = fixture
653            .paths
654            .hold_segments(&used, at(0))
655            .await
656            .expect("the public pair of the path can be held");
657
658        // Every one of the three segments is re-beaconed 600s later: the granted ones by a
659        // re-grant, the public one by the segment manager refetching the pair.
660        let refreshed_at = at(MAX_FETCH_INTERVAL.as_secs() + 1);
661        fixture.grant_for(
662            client_ip(),
663            sni().customer_domain(),
664            AuthSegments {
665                up_segments: vec![up_segment(600)],
666                down_segments: vec![down_segment(600)],
667                ..AuthSegments::default()
668            },
669            refreshed_at,
670        );
671        fixture.fetcher.set_segments(Segments {
672            core_segments: vec![core_segment(600)],
673            ..Segments::default()
674        });
675        fixture.segments.maintain(refreshed_at).await;
676
677        let refreshed = fixture
678            .paths
679            .refresh_path(&used, refreshed_at)
680            .await
681            .expect("refreshing succeeds");
682
683        assert_eq!(
684            refreshed.segments, used.segments,
685            "the refreshed path must use the very same segments, in the same order"
686        );
687        assert_eq!(
688            refreshed.path.fingerprint(),
689            used.path.fingerprint(),
690            "the refreshed path is the same path, so uplinks keep their key"
691        );
692        assert_eq!(
693            refreshed.expiration(),
694            used.expiration().map(|old| old + Duration::from_secs(600)),
695            "all three segments moved, so the whole path lives 600s longer"
696        );
697    }
698
699    #[tokio::test]
700    async fn refresh_path_fails_once_a_public_segment_is_evicted() {
701        let up = up_segment(0);
702        let fixture = Fixture::new(
703            MockFetcher::with_up_segments(vec![up.clone()]),
704            Duration::from_secs(10 * 24 * 3600),
705        );
706        fixture.grant_non_core(Vec::new(), at(0));
707
708        let used = fixture
709            .paths
710            .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
711            .await
712            .expect("path combination succeeds")
713            .expect("the public segment yields a path");
714        assert_eq!(
715            used.segments.as_slice(),
716            [SegmentSourceId::Store(store_id(&up))]
717        );
718
719        // Nothing holds the pair, so it goes once it has been idle for long enough.
720        let evicted_at = at(IDLE_EVICTION_TIME.as_secs() + 1);
721        fixture.segments.maintain(evicted_at).await;
722
723        assert!(
724            fixture.paths.refresh_path(&used, evicted_at).await.is_err(),
725            "a path over an evicted public segment cannot be rebuilt"
726        );
727    }
728
729    #[tokio::test]
730    async fn best_path_fails_when_public_segments_cannot_be_fetched() {
731        let fixture = Fixture::new(MockFetcher::empty(), Duration::from_secs(100));
732        fixture.grant_non_core(vec![up_segment(0)], at(0));
733        fixture.fetcher.set_failing(true);
734
735        assert!(
736            fixture
737                .paths
738                .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
739                .await
740                .is_err(),
741            "a path cannot be computed without knowing the public segments"
742        );
743
744        fixture.fetcher.set_failing(false);
745        assert!(
746            fixture
747                .paths
748                .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
749                .await
750                .expect("path combination succeeds")
751                .is_some(),
752            "and the failure leaves nothing behind that would keep it from succeeding later"
753        );
754    }
755
756    #[tokio::test]
757    async fn refresh_path_picks_up_a_later_expiration() {
758        let fixture = Fixture::new(MockFetcher::empty(), Duration::from_secs(10 * 24 * 3600));
759        fixture.grant_non_core(vec![up_segment(0)], at(0));
760
761        let used = fixture
762            .paths
763            .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
764            .await
765            .expect("path combination succeeds")
766            .expect("the granted segment yields a path");
767
768        // A refresh brings the same segment, beaconed 600s later.
769        let fresher = up_segment(600);
770        assert_eq!(
771            fresher.fingerprint(),
772            up_segment(0).fingerprint(),
773            "a fresher copy of a segment keeps its fingerprint"
774        );
775        fixture.grant_non_core(vec![fresher], at(0));
776
777        let refreshed = fixture
778            .paths
779            .refresh_path(&used, at(0))
780            .await
781            .expect("refreshing succeeds");
782
783        assert_eq!(
784            refreshed.segments, used.segments,
785            "the refreshed path uses the same segments"
786        );
787        assert_eq!(
788            refreshed.path.fingerprint(),
789            used.path.fingerprint(),
790            "the refreshed path is the same path, so uplinks keep their key"
791        );
792        assert_eq!(
793            refreshed.expiration(),
794            used.expiration().map(|old| old + Duration::from_secs(600)),
795            "the refreshed path lives 600s longer"
796        );
797    }
798
799    #[tokio::test]
800    async fn refresh_path_fails_once_the_grant_is_gone() {
801        let fixture = Fixture::new(MockFetcher::empty(), Duration::from_secs(100));
802        fixture.grant_non_core(vec![up_segment(0)], at(0));
803
804        let used = fixture
805            .paths
806            .best_path(client_ip(), &sni(), leaf_ia(), core_ia(), at(0))
807            .await
808            .expect("path combination succeeds")
809            .expect("the granted segment yields a path");
810
811        fixture.grant.clean(at(101));
812
813        assert!(
814            fixture.paths.refresh_path(&used, at(101)).await.is_err(),
815            "a path over a segment whose grant is gone cannot be refreshed"
816        );
817    }
818
819    #[tokio::test]
820    async fn best_path_rejects_wildcards_and_handles_local_paths() {
821        let fixture = Fixture::new(MockFetcher::empty(), Duration::from_secs(100));
822
823        assert!(
824            fixture
825                .paths
826                .best_path(client_ip(), &sni(), leaf_ia(), IsdAsn(0), at(0))
827                .await
828                .is_err()
829        );
830
831        assert!(
832            fixture
833                .paths
834                .best_path(client_ip(), &sni(), leaf_ia(), leaf_ia(), at(0))
835                .await
836                .is_err(),
837            "an AS local path still requires a grant for the target"
838        );
839
840        fixture.grant_non_core(Vec::new(), at(0));
841
842        let used = fixture
843            .paths
844            .best_path(client_ip(), &sni(), leaf_ia(), leaf_ia(), at(0))
845            .await
846            .expect("path combination succeeds")
847            .expect("an AS local path always exists");
848        assert!(used.segments.is_empty());
849    }
850}