1
//! Manipulate time periods (as used in the onion service system)
2

            
3
use std::fmt::Display;
4

            
5
use humantime::format_rfc3339_seconds;
6
use tor_units::IntegerMinutes;
7
use web_time_compat::{Duration, SystemTime};
8

            
9
use serde::{Deserialize, Serialize};
10

            
11
/// A period of time, as used in the onion service system.
12
///
13
/// A `TimePeriod` is defined as a duration (in seconds), and the number of such
14
/// durations that have elapsed since a given offset from the Unix epoch.  So
15
/// for example, the interval "(86400 seconds length, 15 intervals, 12 hours
16
/// offset)", covers `1970-01-16T12:00:00` up to but not including
17
/// `1970-01-17T12:00:00`.
18
///
19
/// These time periods are used to derive a different `BlindedOnionIdKey` during
20
/// each period from each `OnionIdKey`.
21
#[derive(Deserialize, Serialize, Copy, Clone, Debug, Eq, PartialEq, Hash)]
22
pub struct TimePeriod {
23
    /// Index of the time periods that have passed since the unix epoch.
24
    pub(crate) interval_num: u64,
25
    /// The length of a time period, in **minutes**.
26
    ///
27
    /// The spec admits only periods which are a whole number of minutes.
28
    pub(crate) length: IntegerMinutes<u32>,
29
    /// Our offset from the epoch, in seconds.
30
    ///
31
    /// This is the amount of time after the Unix epoch when our epoch begins,
32
    /// rounded down to the nearest second.
33
    pub(crate) epoch_offset_in_sec: u32,
34
}
35

            
36
/// Two [`TimePeriod`]s are ordered with respect to one another if they have the
37
/// same interval length and offset.
38
impl PartialOrd for TimePeriod {
39
4
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
40
4
        if self.length == other.length && self.epoch_offset_in_sec == other.epoch_offset_in_sec {
41
4
            Some(self.interval_num.cmp(&other.interval_num))
42
        } else {
43
            None
44
        }
45
4
    }
46
}
47

            
48
impl Display for TimePeriod {
49
390
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50
390
        write!(f, "#{} ", self.interval_num())?;
51
390
        match self.range() {
52
390
            Ok(r) => {
53
390
                let mins = self.length().as_minutes();
54
390
                write!(
55
390
                    f,
56
390
                    "{}..+{}:{:02}",
57
390
                    format_rfc3339_seconds(r.start),
58
390
                    mins / 60,
59
390
                    mins % 60
60
                )
61
            }
62
            Err(_) => write!(f, "overflow! {self:?}"),
63
        }
64
390
    }
65
}
66

            
67
impl TimePeriod {
68
    /// Construct a time period of a given `length` that contains `when`.
69
    ///
70
    /// The `length` value is rounded down to the nearest second,
71
    /// and must then be a whole number of minutes.
72
    ///
73
    /// The `epoch_offset` value is the amount of time after the Unix epoch when
74
    /// our epoch begins.  It is also rounded down to the nearest second.
75
    ///
76
    /// Return None if the Duration is too large or too small, or if `when`
77
    /// cannot be represented as a time period.
78
30105
    pub fn new(
79
30105
        length: Duration,
80
30105
        when: SystemTime,
81
30105
        epoch_offset: Duration,
82
30105
    ) -> Result<Self, TimePeriodError> {
83
        // The algorithm here is specified in rend-spec-v3 section 2.2.1
84
30105
        let length_in_sec =
85
30105
            u32::try_from(length.as_secs()).map_err(|_| TimePeriodError::IntervalInvalid)?;
86
30105
        if length_in_sec % 60 != 0 || length.subsec_nanos() != 0 {
87
            return Err(TimePeriodError::IntervalInvalid);
88
30105
        }
89
30105
        let length_in_minutes = length_in_sec / 60;
90
30105
        let length = IntegerMinutes::new(length_in_minutes);
91
30105
        let epoch_offset_in_sec =
92
30105
            u32::try_from(epoch_offset.as_secs()).map_err(|_| TimePeriodError::OffsetInvalid)?;
93
30105
        let interval_num = when
94
30105
            .duration_since(SystemTime::UNIX_EPOCH + epoch_offset)
95
30105
            .map_err(|_| TimePeriodError::OutOfRange)?
96
30105
            .as_secs()
97
30105
            / u64::from(length_in_sec);
98
30105
        Ok(TimePeriod {
99
30105
            interval_num,
100
30105
            length,
101
30105
            epoch_offset_in_sec,
102
30105
        })
103
30105
    }
104

            
105
    /// Compute the `TimePeriod`, given its length (in **minutes**), index (the number of time
106
    /// periods that have passed since the unix epoch), and offset from the epoch (in seconds).
107
    ///
108
    /// The `epoch_offset_in_sec` value is the number of seconds after the Unix epoch when our
109
    /// epoch begins, rounded down to the nearest second.
110
    /// Note that this is *not* the time_t at which this *Time Period* begins.
111
    ///
112
    /// The returned TP begins at the time_t `interval_num * length * 60 + epoch_offset_in_sec`
113
    /// and ends `length * 60` seconds later.
114
1046
    pub fn from_parts(length: u32, interval_num: u64, epoch_offset_in_sec: u32) -> Self {
115
1046
        let length_in_sec = length * 60;
116

            
117
1046
        Self {
118
1046
            interval_num,
119
1046
            length: length.into(),
120
1046
            epoch_offset_in_sec,
121
1046
        }
122
1046
    }
123

            
124
    /// Return the time period after this one.
125
    ///
126
    /// Return None if this is the last representable time period.
127
29189
    pub fn next(&self) -> Option<Self> {
128
        Some(TimePeriod {
129
29189
            interval_num: self.interval_num.checked_add(1)?,
130
            ..*self
131
        })
132
29189
    }
133
    /// Return the time period before this one.
134
    ///
135
    /// Return None if this is the first representable time period.
136
29189
    pub fn prev(&self) -> Option<Self> {
137
        Some(TimePeriod {
138
29189
            interval_num: self.interval_num.checked_sub(1)?,
139
            ..*self
140
        })
141
29189
    }
142
    /// Return true if this time period contains `when`.
143
    ///
144
    /// # Limitations
145
    ///
146
    /// This function always returns false if the time period contains any times
147
    /// that cannot be represented as a `SystemTime`.
148
10
    pub fn contains(&self, when: SystemTime) -> bool {
149
10
        match self.range() {
150
10
            Ok(r) => r.contains(&when),
151
            Err(_) => false,
152
        }
153
10
    }
154
    /// Return a range representing the [`SystemTime`] values contained within
155
    /// this time period.
156
    ///
157
    /// Return None if this time period contains any times that can be
158
    /// represented as a `SystemTime`.
159
116559
    pub fn range(&self) -> Result<std::ops::Range<SystemTime>, TimePeriodError> {
160
116559
        (|| {
161
116559
            let length_in_sec = u64::from(self.length.as_minutes()) * 60;
162
116559
            let start_sec = length_in_sec.checked_mul(self.interval_num)?;
163
116559
            let end_sec = start_sec.checked_add(length_in_sec)?;
164
116559
            let epoch_offset = Duration::new(self.epoch_offset_in_sec.into(), 0);
165
116559
            let start = (SystemTime::UNIX_EPOCH + epoch_offset)
166
116559
                .checked_add(Duration::from_secs(start_sec))?;
167
116559
            let end = (SystemTime::UNIX_EPOCH + epoch_offset)
168
116559
                .checked_add(Duration::from_secs(end_sec))?;
169
116559
            Some(start..end)
170
        })()
171
116559
        .ok_or(TimePeriodError::OutOfRange)
172
116559
    }
173

            
174
    /// Return the numeric index of this time period.
175
    ///
176
    /// This function should only be used when encoding the time period for
177
    /// cryptographic purposes.
178
186686
    pub fn interval_num(&self) -> u64 {
179
186686
        self.interval_num
180
186686
    }
181

            
182
    /// Return the length of this time period as a number of seconds.
183
    ///
184
    /// This function should only be used when encoding the time period for
185
    /// cryptographic purposes.
186
186491
    pub fn length(&self) -> IntegerMinutes<u32> {
187
186491
        self.length
188
186491
    }
189

            
190
    /// Return our offset from the epoch, in seconds.
191
    ///
192
    /// Note that this is *not* the start of the TP.
193
    /// See `TimePeriod::from_parts`.
194
16776
    pub fn epoch_offset_in_sec(&self) -> u32 {
195
16776
        self.epoch_offset_in_sec
196
16776
    }
197
}
198

            
199
/// An error that occurs when creating or manipulating a [`TimePeriod`]
200
#[derive(Clone, Debug, thiserror::Error)]
201
#[non_exhaustive]
202
pub enum TimePeriodError {
203
    /// We couldn't represent the time period in the way we were trying to
204
    /// represent it, since it outside of the range supported by the data type.
205
    #[error("Time period out was out of range")]
206
    OutOfRange,
207

            
208
    /// The time period couldn't be constructed because its interval was
209
    /// invalid.
210
    ///
211
    /// (We require that intervals are a multiple of 60 seconds, and that they
212
    /// can be represented in a `u32`.)
213
    #[error("Invalid time period interval")]
214
    IntervalInvalid,
215

            
216
    /// The time period couldn't be constructed because its offset was invalid.
217
    ///
218
    /// (We require that offsets can be represented in a `u32`.)
219
    #[error("Invalid time period offset")]
220
    OffsetInvalid,
221
}
222

            
223
#[cfg(test)]
224
mod test {
225
    // @@ begin test lint list maintained by maint/add_warning @@
226
    #![allow(clippy::bool_assert_comparison)]
227
    #![allow(clippy::clone_on_copy)]
228
    #![allow(clippy::dbg_macro)]
229
    #![allow(clippy::mixed_attributes_style)]
230
    #![allow(clippy::print_stderr)]
231
    #![allow(clippy::print_stdout)]
232
    #![allow(clippy::single_char_pattern)]
233
    #![allow(clippy::unwrap_used)]
234
    #![allow(clippy::unchecked_time_subtraction)]
235
    #![allow(clippy::useless_vec)]
236
    #![allow(clippy::needless_pass_by_value)]
237
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
238

            
239
    use super::*;
240
    use humantime::{parse_duration, parse_rfc3339};
241

            
242
    /// Check reconstructing `period` from parts produces an identical `TimePeriod`.
243
    fn assert_eq_from_parts(period: TimePeriod) {
244
        assert_eq!(
245
            period,
246
            TimePeriod::from_parts(
247
                period.length().as_minutes(),
248
                period.interval_num(),
249
                period.epoch_offset_in_sec()
250
            )
251
        );
252
    }
253

            
254
    #[test]
255
    fn check_testvec() {
256
        // Test case from C tor, taken from rend-spec.
257
        let offset = Duration::new(12 * 60 * 60, 0);
258
        let time = parse_rfc3339("2016-04-13T11:00:00Z").unwrap();
259
        let one_day = parse_duration("1day").unwrap();
260
        let period = TimePeriod::new(one_day, time, offset).unwrap();
261
        assert_eq!(period.interval_num, 16903);
262
        assert!(period.contains(time));
263
        assert_eq_from_parts(period);
264

            
265
        let time = parse_rfc3339("2016-04-13T11:59:59Z").unwrap();
266
        let period = TimePeriod::new(one_day, time, offset).unwrap();
267
        assert_eq!(period.interval_num, 16903); // still the same.
268
        assert!(period.contains(time));
269
        assert_eq_from_parts(period);
270

            
271
        assert_eq!(period.prev().unwrap().interval_num, 16902);
272
        assert_eq!(period.next().unwrap().interval_num, 16904);
273

            
274
        let time2 = parse_rfc3339("2016-04-13T12:00:00Z").unwrap();
275
        let period2 = TimePeriod::new(one_day, time2, offset).unwrap();
276
        assert_eq!(period2.interval_num, 16904);
277
        assert!(period < period2);
278
        assert!(period2 > period);
279
        assert_eq!(period.next().unwrap(), period2);
280
        assert_eq!(period2.prev().unwrap(), period);
281
        assert!(period2.contains(time2));
282
        assert!(!period2.contains(time));
283
        assert!(!period.contains(time2));
284

            
285
        assert_eq!(
286
            period.range().unwrap(),
287
            parse_rfc3339("2016-04-12T12:00:00Z").unwrap()
288
                ..parse_rfc3339("2016-04-13T12:00:00Z").unwrap()
289
        );
290
        assert_eq!(
291
            period2.range().unwrap(),
292
            parse_rfc3339("2016-04-13T12:00:00Z").unwrap()
293
                ..parse_rfc3339("2016-04-14T12:00:00Z").unwrap()
294
        );
295
        assert_eq_from_parts(period2);
296
    }
297
}