Skip to main content

snap_control/pg_wap/crpc/
convert.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//! Translation between the protobuf messages of the WAP control service and their
16//! [`model`]s.
17
18use std::{collections::BTreeMap, time::UNIX_EPOCH};
19
20use scion_protobuf::proto::control_plane::v1 as buffa_cp;
21use sciparse::{rpc::FromRpcError, segment::SignedPathSegment};
22
23use crate::{
24    pg_wap::{
25        crpc::model,
26        sni::{CustomerDomain, SniFormatError},
27    },
28    proto::anapaya::wap::v1 as rpc,
29};
30
31/// Returned when a request cannot be turned into its model.
32///
33/// Everything this covers is a property of the request alone, so it always means the client has
34/// to send a different request rather than send this one again.
35#[derive(thiserror::Error, Debug)]
36pub enum ConvertError {
37    /// The request authorizes nothing, which is never what the client wants.
38    #[error("the request does not name a target")]
39    NoTargets,
40    /// A key of the target map is not a domain name.
41    #[error("the target {target:?} is not a valid domain: {source}")]
42    InvalidTarget {
43        /// The target as it arrived.
44        target: String,
45        /// Why it is not a domain name.
46        source: SniFormatError,
47    },
48    /// A granted segment could not be parsed.
49    #[error("a {kind} segment granted for the target {target:?} is unusable: {source}")]
50    InvalidSegment {
51        /// The target the segment was granted for.
52        target: String,
53        /// Whether the segment was granted as an up, down or core segment.
54        kind: &'static str,
55        /// Why the segment could not be parsed.
56        source: FromRpcError,
57    },
58}
59
60impl rpc::AuthorizeTargetsRequest {
61    /// Builds the model of this request.
62    pub fn try_into_model(self) -> Result<model::AuthorizeTargetsRequest, ConvertError> {
63        if self.targets.is_empty() {
64            return Err(ConvertError::NoTargets);
65        }
66
67        let mut targets = BTreeMap::new();
68        for (target, segments) in self.targets {
69            let segments = segments.try_into_model(&target)?;
70            let domain = CustomerDomain::new(target.clone())
71                .map_err(|source| ConvertError::InvalidTarget { target, source })?;
72
73            targets.insert(domain, segments);
74        }
75
76        Ok(model::AuthorizeTargetsRequest { targets })
77    }
78}
79
80impl rpc::AuthSegments {
81    /// Builds the model of the segments granted for `target`, which names them in the errors.
82    ///
83    /// A single unusable segment fails the whole request: the client granted it because it expects
84    /// the WAP to be able to use it, so silently dropping it would hand out an authorization that
85    /// cannot carry the traffic it was asked for.
86    fn try_into_model(self, target: &str) -> Result<model::AuthSegments, ConvertError> {
87        let Self {
88            up_segments,
89            down_segments,
90            core_segments,
91            ..
92        } = self;
93
94        Ok(model::AuthSegments {
95            up_segments: parse_segments(target, "up", up_segments)?,
96            down_segments: parse_segments(target, "down", down_segments)?,
97            core_segments: parse_segments(target, "core", core_segments)?,
98        })
99    }
100}
101
102fn parse_segments(
103    target: &str,
104    kind: &'static str,
105    segments: Vec<buffa_cp::PathSegment>,
106) -> Result<Vec<SignedPathSegment>, ConvertError> {
107    segments
108        .into_iter()
109        .map(|segment| {
110            SignedPathSegment::try_from_rpc(segment).map_err(|source| {
111                ConvertError::InvalidSegment {
112                    target: target.to_owned(),
113                    kind,
114                    source,
115                }
116            })
117        })
118        .collect()
119}
120
121impl From<model::AuthorizeTargetsResponse> for rpc::AuthorizeTargetsResponse {
122    fn from(response: model::AuthorizeTargetsResponse) -> Self {
123        Self {
124            client_ip: response.client_ip.to_string(),
125            wap_id: response.wap_id,
126            data_plane_port: u32::from(response.data_plane_port),
127            // An expiry before the epoch cannot be expressed on the wire, and a `0` is as good a
128            // stand-in as any: it is in the past either way.
129            expiry_time: response
130                .expiry_time
131                .duration_since(UNIX_EPOCH)
132                .unwrap_or_default()
133                .as_secs(),
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use std::time::{Duration, SystemTime};
141
142    use super::*;
143    use crate::pg_wap::test_util::{client_ip, core_segment, down_segment, sni, up_segment};
144
145    /// A request granting `segments` for the customer domain of [`sni`].
146    fn request(segments: rpc::AuthSegments) -> rpc::AuthorizeTargetsRequest {
147        rpc::AuthorizeTargetsRequest {
148            targets: [(sni().customer_domain().as_str().to_owned(), segments)]
149                .into_iter()
150                .collect(),
151        }
152    }
153
154    fn fingerprints(segments: &[SignedPathSegment]) -> Vec<sciparse::segment::SegmentFp> {
155        segments
156            .iter()
157            .map(SignedPathSegment::fingerprint)
158            .collect()
159    }
160
161    #[test]
162    fn a_target_is_keyed_by_its_customer_domain_and_keeps_its_segments_grouped() {
163        let (up, down, core) = (up_segment(0), down_segment(0), core_segment(0));
164
165        let converted = request(rpc::AuthSegments {
166            up_segments: vec![up.clone().into_rpc()],
167            down_segments: vec![down.clone().into_rpc()],
168            core_segments: vec![core.clone().into_rpc()],
169        })
170        .try_into_model()
171        .expect("the request is convertible");
172
173        assert_eq!(
174            converted.targets.keys().collect::<Vec<_>>(),
175            vec![&CustomerDomain::from(sni().customer_domain())]
176        );
177
178        let granted = converted
179            .targets
180            .get(sni().customer_domain().as_str())
181            .expect("the target is in the request");
182        assert_eq!(fingerprints(&granted.up_segments), fingerprints(&[up]));
183        assert_eq!(fingerprints(&granted.down_segments), fingerprints(&[down]));
184        assert_eq!(fingerprints(&granted.core_segments), fingerprints(&[core]));
185    }
186
187    #[test]
188    fn a_request_without_targets_is_not_convertible() {
189        let err = rpc::AuthorizeTargetsRequest::default()
190            .try_into_model()
191            .expect_err("a request without targets is rejected");
192
193        assert!(matches!(err, ConvertError::NoTargets), "got {err}");
194    }
195
196    #[test]
197    fn a_target_that_is_not_a_domain_is_not_convertible() {
198        let err = rpc::AuthorizeTargetsRequest {
199            targets: [("not a domain".to_owned(), rpc::AuthSegments::default())]
200                .into_iter()
201                .collect(),
202        }
203        .try_into_model()
204        .expect_err("a target that is not a domain is rejected");
205
206        assert!(
207            matches!(err, ConvertError::InvalidTarget { .. }),
208            "got {err}"
209        );
210    }
211
212    #[test]
213    fn a_segment_that_cannot_be_parsed_is_not_convertible() {
214        let err = request(rpc::AuthSegments {
215            up_segments: vec![buffa_cp::PathSegment {
216                segment_info: vec![0xff, 0xff],
217                as_entries: Vec::new(),
218            }],
219            ..rpc::AuthSegments::default()
220        })
221        .try_into_model()
222        .expect_err("an unparseable segment is rejected");
223
224        assert!(
225            matches!(err, ConvertError::InvalidSegment { kind: "up", .. }),
226            "got {err}"
227        );
228    }
229
230    #[test]
231    fn a_response_carries_the_expiry_as_seconds_since_the_epoch() {
232        let response = rpc::AuthorizeTargetsResponse::from(model::AuthorizeTargetsResponse {
233            client_ip: client_ip(),
234            wap_id: "wap-1".to_owned(),
235            data_plane_port: 8443,
236            expiry_time: UNIX_EPOCH + Duration::from_secs(1_800),
237        });
238
239        assert_eq!(
240            response,
241            rpc::AuthorizeTargetsResponse {
242                client_ip: client_ip().to_string(),
243                wap_id: "wap-1".to_owned(),
244                data_plane_port: 8443,
245                expiry_time: 1_800,
246            }
247        );
248    }
249
250    #[test]
251    fn an_expiry_before_the_epoch_becomes_zero() {
252        let response = rpc::AuthorizeTargetsResponse::from(model::AuthorizeTargetsResponse {
253            client_ip: client_ip(),
254            wap_id: "wap-1".to_owned(),
255            data_plane_port: 8443,
256            expiry_time: SystemTime::UNIX_EPOCH - Duration::from_secs(1),
257        });
258
259        assert_eq!(response.expiry_time, 0);
260    }
261}