1
//! Computing microdescriptors
2

            
3
use super::*;
4

            
5
/// Error creating a microdescriptor
6
#[derive(Debug, Clone, thiserror::Error)]
7
#[non_exhaustive]
8
pub enum MicrodescError {
9
    /// Internal error
10
    #[error("internal error")]
11
    Internal(#[from] Bug),
12
}
13

            
14
/// Compute a microdescriptor from a routerdesc
15
///
16
/// <https://spec.torproject.org/dir-spec/computing-microdescriptors.html>
17
62
pub fn compute_microdesc(
18
62
    rd: &RouterDesc,
19
62
    meth: &TrackedConsensusMethod,
20
62
) -> Result<Microdesc, MicrodescError> {
21
62
    let mut family = RelayFamily::clone(&rd.family); // avoids Arc::Clone
22
62
    if !family.is_empty() {
23
50
        family.push(rd.signing_key.to_rsa_identity());
24
50
    }
25
62
    let family = family.intern(); // also normalises
26

            
27
62
    let family_ids: RelayFamilyIds = rd
28
62
        .family_cert
29
62
        .iter()
30
81
        .map(|cert| Ok::<_, Bug>(cert.get()?.family_ed25519.into()))
31
62
        .try_collect()?;
32

            
33
60
    let ipv4_policy =
34
60
        ip_summary::summarise_policy_v4_approximate(&rd.ipv4_policy, meth)?.into_intern();
35

            
36
60
    let m = Microdesc {
37
60
        family,
38
60
        family_ids,
39
60
        ipv4_policy,
40
60
        ipv6_policy: rd.ipv6_policy.clone(),
41
        ..MicrodescConstructor {
42
60
            ntor_onion_key: rd.ntor_onion_key.clone(),
43
60
            ed25519_id: rd.identity_ed25519.get()?.id_ed25519.into(),
44
        }
45
60
        .construct()
46
    };
47
60
    Ok(m)
48
62
}
49

            
50
/// Microdescriptors computed from one routerdesc, for various supported consensus methods
51
pub type MicrodescsForRouterDesc = BTreeMap<tor_netdoc::doc::netstatus::ConsensusMethods, String>;
52

            
53
/// Errors calculating one or more microdescriptors
54
pub type MicrodescsErrors = Vec<(SupportedConsensusMethod, MicrodescError)>;
55

            
56
/// Computes the microdescriptor for this descriptor, for each supported consensus method
57
///
58
/// If all goes well returns `MicrodescsForRouterDesc`, a map from methods to strings.
59
/// The map is guaranteed not to have the same *value* for different keys:
60
/// if different emthods yield the same microdesc, thy will be represented as
61
/// a single `ConsensusMethods` set mapping to a single `String`.
62
///
63
/// Hashing (calculating and collating document digests) is done by the caller of this function.
64
///
65
/// If any microdesc couldn't be computed (currently, only possible due to internal errors)
66
/// returns an `Err` containing both the failed consensus method(s) and the corresponding errors,
67
/// and the successful results (if any).
68
///
69
/// This unusual error handling is so that a handful of strange routerdescs,
70
/// that trigger bugs in our code, do not cause the whole algorithm to collapse.
71
/// See torspec!522  TODO DIRAUTH turn this into a proper spec ref when that's merged.
72
32
pub fn compute_supported_microdescs(
73
32
    rd: &RouterDesc,
74
32
) -> Result<MicrodescsForRouterDesc, (MicrodescsErrors, MicrodescsForRouterDesc)> {
75
32
    compute_supported_generic(
76
        //
77
32
        SupportedConsensusMethod::iter_all(),
78
32
        |tracker| {
79
32
            let md = compute_microdesc(rd, tracker)?;
80
30
            let md = encode_netdoc_unsigned([&md])?;
81
30
            Ok(md)
82
32
        },
83
    )
84
32
}
85

            
86
/// "Good" output from [`compute_supported_generic`]
87
type Outputs<O> = BTreeMap<tor_netdoc::doc::netstatus::ConsensusMethods, O>;
88

            
89
/// Compute `O` for all supported consensus methods
90
///
91
/// This is the core of `compute_supported_microdescs`,
92
/// but made generic so that we can pass an interesting calculation function, for testing.
93
#[allow(clippy::type_complexity)] // return type is sadly rather complex
94
34
fn compute_supported_generic<O: Clone + Eq + Hash, E: From<Bug>>(
95
34
    all_methods: impl Iterator<Item = SupportedConsensusMethod>,
96
34
    computor: impl Fn(&TrackedConsensusMethod) -> Result<O, E>,
97
34
) -> Result<Outputs<O>, (Vec<(SupportedConsensusMethod, E)>, Outputs<O>)> {
98
34
    let mut results = HashMap::<O, tor_netdoc::doc::netstatus::ConsensusMethods>::new();
99
34
    let mut errors = vec![];
100

            
101
34
    let mut last = None::<(ConsensusMethodRange, O)>;
102
232
    for method in all_methods {
103
232
        (|| {
104
232
            let entry = if let Some((range, prev)) = &last
105
198
                && range.contains(&method)
106
            {
107
178
                results
108
178
                    .get_mut(prev)
109
178
                    .ok_or_else(|| internal!("prev not inserted!"))?
110
            } else {
111
54
                let tracker = TrackedConsensusMethod::new(method);
112
54
                let output = computor(&tracker)?;
113
50
                last = Some((tracker.finish_get_equivalent(), output.clone()));
114
50
                results.entry(output).or_default()
115
            };
116
228
            entry.methods.insert(method.into());
117
228
            Ok(())
118
        })()
119
232
        .unwrap_or_else(|e| {
120
4
            errors.push((method, e));
121
4
        });
122
    }
123

            
124
34
    let results = results
125
34
        .into_iter()
126
44
        .map(|(output, meths)| (meths, output))
127
34
        .collect();
128
34
    if errors.is_empty() {
129
30
        Ok(results)
130
    } else {
131
4
        Err((errors, results))
132
    }
133
34
}
134

            
135
#[cfg(test)]
136
mod test {
137
    // @@ begin test lint list maintained by maint/add_warning @@
138
    #![allow(clippy::bool_assert_comparison)]
139
    #![allow(clippy::clone_on_copy)]
140
    #![allow(clippy::dbg_macro)]
141
    #![allow(clippy::mixed_attributes_style)]
142
    #![allow(clippy::print_stderr)]
143
    #![allow(clippy::print_stdout)]
144
    #![allow(clippy::single_char_pattern)]
145
    #![allow(clippy::unwrap_used)]
146
    #![allow(clippy::unchecked_time_subtraction)]
147
    #![allow(clippy::useless_vec)]
148
    #![allow(clippy::needless_pass_by_value)]
149
    #![allow(clippy::string_slice)] // See arti#2571
150
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
151
    use super::*;
152
    use std::rc::Rc;
153
    use tor_checkable::TimeBound as _;
154
    use tor_error::ErrorReport as _;
155
    use tor_netdoc::{
156
        assert_eq_or_diff,
157
        doc::routerdesc::RouterDescUnverified,
158
        parse2::{NetdocParseableUnverified as _, ParseInput, parse_netdoc},
159
        test_support::regsub,
160
        testdata_live,
161
    };
162

            
163
    fn get_router_desc(relay: &testdata_live::PerRelay) -> anyhow::Result<RouterDesc> {
164
        let rd_txt = relay.data.plain;
165
        let rd: RouterDescUnverified = parse_netdoc(&ParseInput::new(rd_txt, relay.nick))?;
166
        let rd = rd.verify()?.dangerously_assume_timely();
167
        Ok(rd)
168
    }
169

            
170
    #[test]
171
    fn microdescs() -> anyhow::Result<()> {
172
        let method = TrackedConsensusMethod::new(
173
            // Test with method 100; will need to bump this occasionally,
174
            // and/or add more regsub fixups, if this test fails.
175
            ConsensusMethod(100).try_into()?,
176
        );
177

            
178
        for relay in testdata_live::RELAY_DESCRIPTORS {
179
            let rd = get_router_desc(relay)?;
180
            let md = compute_microdesc(&rd, &method)?;
181
            let md_txt = encode_netdoc_unsigned([&md])?;
182

            
183
            let mut exp_md = relay.data.md.to_owned();
184
            regsub(
185
                &mut exp_md,
186
                r#"(?x) ^ onion-key \n
187
                          -----BEGIN\ .*----- \n
188
                          [^-]+
189
                          -----END\ .*----- \n
190
                "#,
191
                "onion-key\n",
192
            );
193

            
194
            assert_eq_or_diff!(md_txt, exp_md, "for relay {}", relay.nick);
195
        }
196

            
197
        Ok(())
198
    }
199

            
200
    #[test]
201
    fn all_microdesc() -> anyhow::Result<()> {
202
        for relay in testdata_live::RELAY_DESCRIPTORS {
203
            let rd = get_router_desc(relay)?;
204
            let descs = compute_supported_microdescs(&rd).expect("no bugs");
205
            for md in descs.values() {
206
                let _md: Microdesc = parse_netdoc(&ParseInput::new(md, relay.nick))?;
207
            }
208
            itertools::assert_equal(
209
                descs
210
                    .keys()
211
                    .flat_map(|m| m.methods.iter())
212
                    .sorted()
213
                    .copied(),
214
                SupportedConsensusMethod::iter_all().map(|m| *m),
215
            );
216
        }
217
        Ok(())
218
    }
219

            
220
    #[test]
221
    #[allow(clippy::len_zero)] // assert!(!foo.is_empty()) is just terrible with all the subtle !
222
    fn buggy_microdesc() -> anyhow::Result<()> {
223
        // Test error handling of microdescriptor construction.
224
        // It turns out we can sabotage microdescriptor construction if we bypass
225
        // RouterDesc::verify, because that fails to populate family info that we need.
226
        let relay = &testdata_live::RELAY_DESCRIPTORS[0];
227
        let rd_txt = relay.data.plain;
228
        let rd: RouterDescUnverified = parse_netdoc(&ParseInput::new(rd_txt, relay.nick))?;
229
        let rd = rd.unwrap_unverified().0;
230
        let (errors, descs) = compute_supported_microdescs(&rd)
231
            .expect_err("should access unverified EmbeddedCert, throwing Bug");
232
        assert!(descs.len() == 0);
233
        assert!(errors.len() > 0);
234
        for (method, error) in errors {
235
            let _: SupportedConsensusMethod = method;
236
            let msg = error.report().to_string();
237
            let exp = "attempted to access verified data of unverified EmbeddedCert";
238
            assert!(msg.contains(exp), "{msg:?}");
239
        }
240
        Ok(())
241
    }
242

            
243
    #[test]
244
    fn all_supported_track_coalesce() {
245
        use crate::consensus::tracked_method::test::interesting_function;
246

            
247
        #[derive(Debug, thiserror::Error)]
248
        enum TestCaseError {
249
            #[error("expected")]
250
            Expected,
251
            #[error("{0:?}")]
252
            Internal(#[from] Bug),
253
        }
254

            
255
        let n_calls = Cell::new(0);
256

            
257
        let (errors, good) = compute_supported_generic(
258
            (1..=100)
259
                .map(ConsensusMethod)
260
                .map(SupportedConsensusMethod::new_unchecked),
261
            |tracker| {
262
                n_calls.update(|n_calls| n_calls + 1);
263
                eprintln!("called with {tracker:?}");
264

            
265
                if tracker == 80 {
266
                    Err(TestCaseError::Expected)
267
                } else {
268
                    let v = interesting_function(tracker);
269
                    Ok(Rc::new(v))
270
                }
271
            },
272
        )
273
        .expect_err("80 fails");
274

            
275
        // Check that we got the expected error
276
        match &*errors {
277
            [(m, TestCaseError::Expected)] if *m == 80 => {}
278
            other => panic!("{other:?}"),
279
        }
280

            
281
        // Check that results are consistent with calculating every value
282
        for (ms, o) in good {
283
            for &m in &ms.methods {
284
                let m = SupportedConsensusMethod::new_unchecked(m);
285
                let exp = interesting_function(&TrackedConsensusMethod::new(m));
286
                assert_eq!(*o, exp);
287
            }
288
        }
289

            
290
        // Check how many calculations were needed:
291
        assert_eq!(n_calls.get(), 11);
292
    }
293
}