Skip to main content

irtt_client/session/
negotiation.rs

1use std::{fmt, time::Duration};
2
3use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
4
5use crate::{config::NegotiationPolicy, error::ClientError};
6
7/// Validated session semantics accepted after a successful Open exchange.
8///
9/// These values drive ordinary client behavior. The exact peer-returned wire
10/// representation is available separately in [`NegotiationResult::peer_params`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AcceptedSessionParameters {
13    /// `None` means continuous mode; finite durations are positive.
14    pub duration: Option<Duration>,
15    /// Positive interval between probes when driven by a scheduler.
16    pub interval: Duration,
17    /// Accepted packet length in the usable unsigned domain.
18    pub length: u32,
19    pub received_stats: ReceivedStats,
20    pub stamp_at: StampAt,
21    pub clock: Clock,
22    /// DSCP codepoint (`0..=63`), as in [`crate::SessionRequest::dscp`].
23    pub dscp: u8,
24    /// Accepted server payload fill, preserving the peer's string value.
25    pub server_fill: Option<String>,
26}
27
28/// Accepted session semantics, exact protocol evidence, and accepted changes.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct NegotiationResult {
31    pub accepted: AcceptedSessionParameters,
32    /// Exact decoded parameters returned by the peer, without normalization.
33    ///
34    /// Echo encoding/decoding uses these accepted wire values. `dscp` here is
35    /// the raw IP TOS / Traffic Class byte, unlike [`AcceptedSessionParameters::dscp`].
36    pub peer_params: Params,
37    /// Differences accepted under [`crate::NegotiationPolicy::Loose`].
38    pub changes: Vec<NegotiationChange>,
39}
40
41/// A semantic parameter change accepted during negotiation.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum NegotiationChange {
44    /// A finite duration was reduced, or continuous mode was limited.
45    DurationReduced {
46        /// `None` means the request was continuous.
47        requested: Option<Duration>,
48        negotiated: Duration,
49    },
50    IntervalIncreased {
51        requested: Duration,
52        negotiated: Duration,
53    },
54    /// Loose policy accepts any positive reduced interval.
55    IntervalReduced {
56        requested: Duration,
57        negotiated: Duration,
58    },
59    LengthReduced {
60        requested: u32,
61        negotiated: u32,
62    },
63    ReceivedStatsChanged {
64        requested: ReceivedStats,
65        negotiated: ReceivedStats,
66    },
67    StampAtChanged {
68        requested: StampAt,
69        negotiated: StampAt,
70    },
71    ClockChanged {
72        requested: Clock,
73        negotiated: Clock,
74    },
75    /// Both values are DSCP codepoints, rather than raw Traffic Class bytes.
76    DscpChanged {
77        requested: u8,
78        negotiated: u8,
79    },
80    ServerFillChanged {
81        requested: Option<String>,
82        negotiated: Option<String>,
83    },
84}
85
86impl NegotiationChange {
87    /// Return a human-readable description of this accepted change.
88    pub fn message(&self) -> String {
89        self.to_string()
90    }
91}
92
93impl fmt::Display for NegotiationChange {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::DurationReduced {
97                requested: None,
98                negotiated,
99            } => {
100                write!(
101                    f,
102                    "server limited continuous duration to {} ns",
103                    negotiated.as_nanos()
104                )
105            }
106            Self::DurationReduced {
107                requested: Some(requested),
108                negotiated,
109            } => {
110                write!(
111                    f,
112                    "server reduced duration from {} ns to {} ns",
113                    requested.as_nanos(),
114                    negotiated.as_nanos()
115                )
116            }
117            Self::IntervalIncreased {
118                requested,
119                negotiated,
120            } => {
121                write!(
122                    f,
123                    "server increased interval from {} ns to {} ns",
124                    requested.as_nanos(),
125                    negotiated.as_nanos()
126                )
127            }
128            Self::IntervalReduced {
129                requested,
130                negotiated,
131            } => {
132                write!(
133                    f,
134                    "server reduced interval from {} ns to {} ns",
135                    requested.as_nanos(),
136                    negotiated.as_nanos()
137                )
138            }
139            Self::LengthReduced {
140                requested,
141                negotiated,
142            } => {
143                write!(
144                    f,
145                    "server reduced packet length from {requested} bytes to {negotiated} bytes"
146                )
147            }
148            Self::ReceivedStatsChanged {
149                requested,
150                negotiated,
151            } => {
152                write!(
153                    f,
154                    "server changed received-stats from {requested:?} to {negotiated:?}"
155                )
156            }
157            Self::StampAtChanged {
158                requested,
159                negotiated,
160            } => {
161                write!(
162                    f,
163                    "server changed stamp-at from {requested:?} to {negotiated:?}"
164                )
165            }
166            Self::ClockChanged {
167                requested,
168                negotiated,
169            } => {
170                write!(
171                    f,
172                    "server changed clock from {requested:?} to {negotiated:?}"
173                )
174            }
175            Self::DscpChanged {
176                requested,
177                negotiated,
178            } => {
179                write!(f, "server changed DSCP from {requested} to {negotiated}")
180            }
181            Self::ServerFillChanged { .. } => write!(f, "server changed payload fill behavior"),
182        }
183    }
184}
185
186// Called only after validating the request and returned numeric domains.
187fn positive_duration(nanos: i64) -> Duration {
188    Duration::from_nanos(u64::try_from(nanos).expect("validated non-negative nanoseconds"))
189}
190
191fn finite_duration(nanos: i64) -> Option<Duration> {
192    (nanos != 0).then(|| positive_duration(nanos))
193}
194
195pub(crate) fn negotiate_params(
196    requested: &Params,
197    returned: Params,
198    policy: NegotiationPolicy,
199) -> Result<NegotiationResult, ClientError> {
200    if returned.protocol_version != PROTOCOL_VERSION {
201        return Err(ClientError::ProtocolVersionMismatch {
202            requested: PROTOCOL_VERSION,
203            received: returned.protocol_version,
204        });
205    }
206    let mut changes = Vec::new();
207
208    validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
209    if returned.length < 0 {
210        return Err(ClientError::NegotiationRejected {
211            reason: "length must be non-negative".to_owned(),
212        });
213    }
214    if returned.length > requested.length {
215        return Err(ClientError::NegotiationRejected {
216            reason: "length increased".to_owned(),
217        });
218    }
219    if returned.interval_ns <= 0 {
220        return Err(ClientError::NegotiationRejected {
221            reason: "interval must be positive".to_owned(),
222        });
223    }
224    validate_dscp_restriction(returned.dscp)?;
225
226    if returned.duration_ns < requested.duration_ns
227        || (requested.duration_ns == 0 && returned.duration_ns > 0)
228    {
229        record_change(
230            policy,
231            &mut changes,
232            NegotiationChange::DurationReduced {
233                requested: finite_duration(requested.duration_ns),
234                negotiated: positive_duration(returned.duration_ns),
235            },
236        )?;
237    }
238    if returned.interval_ns > requested.interval_ns {
239        record_change(
240            policy,
241            &mut changes,
242            NegotiationChange::IntervalIncreased {
243                requested: positive_duration(requested.interval_ns),
244                negotiated: positive_duration(returned.interval_ns),
245            },
246        )?;
247    }
248    if returned.interval_ns < requested.interval_ns {
249        record_change(
250            policy,
251            &mut changes,
252            NegotiationChange::IntervalReduced {
253                requested: positive_duration(requested.interval_ns),
254                negotiated: positive_duration(returned.interval_ns),
255            },
256        )?;
257    }
258    if returned.length < requested.length {
259        record_change(
260            policy,
261            &mut changes,
262            NegotiationChange::LengthReduced {
263                requested: u32::try_from(requested.length).expect("validated requested length"),
264                negotiated: u32::try_from(returned.length).expect("validated returned length"),
265            },
266        )?;
267    }
268    if returned.received_stats != requested.received_stats {
269        record_change(
270            policy,
271            &mut changes,
272            NegotiationChange::ReceivedStatsChanged {
273                requested: requested.received_stats,
274                negotiated: returned.received_stats,
275            },
276        )?;
277    }
278    if returned.stamp_at != requested.stamp_at {
279        record_change(
280            policy,
281            &mut changes,
282            NegotiationChange::StampAtChanged {
283                requested: requested.stamp_at,
284                negotiated: returned.stamp_at,
285            },
286        )?;
287    }
288    if returned.clock != requested.clock {
289        record_change(
290            policy,
291            &mut changes,
292            NegotiationChange::ClockChanged {
293                requested: requested.clock,
294                negotiated: returned.clock,
295            },
296        )?;
297    }
298    if returned.dscp != requested.dscp && returned.dscp != 0 {
299        return Err(ClientError::NegotiationRejected {
300            reason: "server returned unsupported DSCP change".to_owned(),
301        });
302    }
303    if returned.dscp == 0 && requested.dscp != 0 {
304        record_change(
305            policy,
306            &mut changes,
307            NegotiationChange::DscpChanged {
308                // `requested`/`negotiated` here are raw wire TOS/Traffic Class
309                // bytes; shift back to the codepoint the user actually
310                // configured for a semantic change record.
311                requested: u8::try_from(requested.dscp >> 2).expect("validated requested DSCP"),
312                negotiated: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
313            },
314        )?;
315    }
316    if returned.server_fill != requested.server_fill {
317        record_change(
318            policy,
319            &mut changes,
320            NegotiationChange::ServerFillChanged {
321                requested: requested
322                    .server_fill
323                    .as_ref()
324                    .map(|fill| fill.value.clone()),
325                negotiated: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
326            },
327        )?;
328    }
329
330    // Compare the exact wire values above before deriving accepted semantics.
331    // In particular, do not normalize fill or traffic class before Strict policy.
332    let accepted = AcceptedSessionParameters {
333        duration: finite_duration(returned.duration_ns),
334        interval: positive_duration(returned.interval_ns),
335        length: u32::try_from(returned.length).expect("validated returned length"),
336        received_stats: returned.received_stats,
337        stamp_at: returned.stamp_at,
338        clock: returned.clock,
339        dscp: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
340        server_fill: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
341    };
342    Ok(NegotiationResult {
343        accepted,
344        peer_params: returned,
345        changes,
346    })
347}
348
349fn validate_duration_restriction(requested: i64, returned: i64) -> Result<(), ClientError> {
350    if returned < 0 {
351        return Err(ClientError::NegotiationRejected {
352            reason: "duration must be non-negative".to_owned(),
353        });
354    }
355
356    if requested > 0 && returned == 0 {
357        return Err(ClientError::NegotiationRejected {
358            reason: "server returned continuous duration for finite request".to_owned(),
359        });
360    }
361
362    if requested > 0 && returned > requested {
363        return Err(ClientError::NegotiationRejected {
364            reason: "duration increased".to_owned(),
365        });
366    }
367
368    Ok(())
369}
370
371/// Validates a server-returned `Params::dscp`, which is a raw IP TOS /
372/// Traffic Class byte and therefore valid across the full `0..=255` wire
373/// range, not the `0..=`[`MAX_DSCP_CODEPOINT`](crate::config::MAX_DSCP_CODEPOINT)
374/// range of the public codepoint.
375fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
376    if !(0..=255).contains(&returned) {
377        return Err(ClientError::NegotiationRejected {
378            reason: "dscp must be in range 0..=255".to_owned(),
379        });
380    }
381
382    Ok(())
383}
384
385fn record_change(
386    policy: NegotiationPolicy,
387    changes: &mut Vec<NegotiationChange>,
388    change: NegotiationChange,
389) -> Result<(), ClientError> {
390    if policy == NegotiationPolicy::Strict {
391        return Err(ClientError::NegotiationRejected {
392            reason: change.message(),
393        });
394    }
395
396    changes.push(change);
397    Ok(())
398}