tor_netdoc/
build.rs

1//! Building support for the network document meta-format
2//!
3//! Implements building documents according to
4//! [dir-spec.txt](https://spec.torproject.org/dir-spec).
5//! section 1.2 and 1.3.
6//!
7//! This facility processes output that complies with the meta-document format,
8//! (`dir-spec.txt` section 1.2) -
9//! unless `raw` methods are called with improper input.
10//!
11//! However, no checks are done on keyword presence/absence, multiplicity, or ordering,
12//! so the output may not necessarily conform to the format of the particular intended document.
13//! It is the caller's responsibility to call `.item()` in the right order,
14//! with the right keywords and arguments.
15
16use std::fmt::{Display, Write};
17
18use base64ct::{Base64, Base64Unpadded, Encoding};
19use rand::{CryptoRng, RngCore};
20use tor_bytes::EncodeError;
21use tor_error::{Bug, internal};
22
23use crate::parse::keyword::Keyword;
24use crate::parse::tokenize::tag_keywords_ok;
25use crate::types::misc::Iso8601TimeSp;
26
27/// Encoder, representing a partially-built document.
28///
29/// For example usage, see the tests in this module, or a descriptor building
30/// function in tor-netdoc (such as `hsdesc::build::inner::HsDescInner::build_sign`).
31#[derive(Debug, Clone)]
32pub(crate) struct NetdocEncoder {
33    /// The being-built document, with everything accumulated so far
34    ///
35    /// If an [`ItemEncoder`] exists, it will add a newline when it's dropped.
36    ///
37    /// `Err` means bad values passed to some builder function
38    built: Result<String, Bug>,
39}
40
41/// Encoder for an individual item within a being-built document
42///
43/// Returned by [`NetdocEncoder::item()`].
44#[derive(Debug)]
45pub(crate) struct ItemEncoder<'n> {
46    /// The document including the partial item that we're building
47    ///
48    /// We will always add a newline when we're dropped
49    doc: &'n mut NetdocEncoder,
50}
51
52/// Position within a (perhaps partially-) built document
53///
54/// This is provided mainly to allow the caller to perform signature operations
55/// on the part of the document that is to be signed.
56/// (Sometimes this is only part of it.)
57///
58/// There is no enforced linkage between this and the document it refers to.
59#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
60pub(crate) struct Cursor {
61    /// The offset (in bytes, as for `&str`)
62    ///
63    /// Can be out of range if the corresponding `NetdocEncoder` is contains an `Err`.
64    offset: usize,
65}
66
67/// Types that can be added as argument(s) to item keyword lines
68///
69/// Implemented for strings, and various other types.
70///
71/// This is a separate trait so we can control the formatting of (eg) [`Iso8601TimeSp`],
72/// without having a method on `ItemEncoder` for each argument type.
73pub(crate) trait ItemArgument {
74    /// Format as a string suitable for including as a netdoc keyword line argument
75    ///
76    /// The implementation is responsible for checking that the syntax is legal.
77    /// For example, if `self` is a string, it must check that the string is
78    /// in legal as a single argument.
79    ///
80    /// Some netdoc values (eg times) turn into several arguments; in that case,
81    /// one `ItemArgument` may format into multiple arguments, and this method
82    /// is responsible for writing them all, with the necessary spaces.
83    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug>;
84}
85
86impl NetdocEncoder {
87    /// Start encoding a document
88    pub(crate) fn new() -> Self {
89        NetdocEncoder {
90            built: Ok(String::new()),
91        }
92    }
93
94    /// Adds an item to the being-built document
95    ///
96    /// The item can be further extended with arguments or an object,
97    /// using the returned `ItemEncoder`.
98    pub(crate) fn item(&mut self, keyword: impl Keyword) -> ItemEncoder {
99        self.raw(&keyword.to_str());
100        ItemEncoder { doc: self }
101    }
102
103    /// Internal name for `push_raw_string()`
104    fn raw(&mut self, s: &dyn Display) {
105        self.write_with(|b| {
106            write!(b, "{}", s).expect("write! failed on String");
107            Ok(())
108        });
109    }
110
111    /// Extend the being-built document with a fallible function `f`
112    ///
113    /// Doesn't call `f` if the building has already failed,
114    /// and handles the error if `f` fails.
115    fn write_with(&mut self, f: impl FnOnce(&mut String) -> Result<(), Bug>) {
116        let Ok(build) = &mut self.built else {
117            return;
118        };
119        match f(build) {
120            Ok(()) => (),
121            Err(e) => {
122                self.built = Err(e);
123            }
124        }
125    }
126
127    /// Adds raw text to the being-built document
128    ///
129    /// `s` is added as raw text, after the newline ending the previous item.
130    /// If `item` is subsequently called, the start of that item
131    /// will immediately follow `s`.
132    ///
133    /// It is the responsibility of the caller to obey the metadocument syntax.
134    /// In particular, `s` should end with a newline.
135    /// No checks are performed.
136    /// Incorrect use might lead to malformed documents, or later errors.
137    #[allow(dead_code)] // TODO: We should remove this if it never used.
138    pub(crate) fn push_raw_string(&mut self, s: &dyn Display) {
139        self.raw(s);
140    }
141
142    /// Return a cursor, pointing to just after the last item (if any)
143    pub(crate) fn cursor(&self) -> Cursor {
144        let offset = match &self.built {
145            Ok(b) => b.len(),
146            Err(_) => usize::MAX,
147        };
148        Cursor { offset }
149    }
150
151    /// Obtain the text of a section of the document
152    ///
153    /// Useful for making a signature.
154    pub(crate) fn slice(&self, begin: Cursor, end: Cursor) -> Result<&str, Bug> {
155        self.built
156            .as_ref()
157            .map_err(Clone::clone)?
158            .get(begin.offset..end.offset)
159            .ok_or_else(|| internal!("NetdocEncoder::slice out of bounds, Cursor mismanaged"))
160    }
161
162    /// Build the document into textual form
163    pub(crate) fn finish(self) -> Result<String, Bug> {
164        self.built
165    }
166}
167
168impl ItemArgument for str {
169    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
170        // Implements this
171        // https://gitlab.torproject.org/tpo/core/torspec/-/merge_requests/106
172        if self.is_empty() || self.chars().any(|c| !c.is_ascii_graphic()) {
173            return Err(internal!("invalid keyword argument syntax {:?}", self));
174        }
175        out.args_raw_nonempty(&self);
176        Ok(())
177    }
178}
179
180impl ItemArgument for &str {
181    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
182        <str as ItemArgument>::write_onto(self, out)
183    }
184}
185
186impl<T: crate::NormalItemArgument> ItemArgument for T {
187    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
188        let arg = self.to_string();
189        out.add_arg(&arg.as_str());
190        Ok(())
191    }
192}
193
194impl ItemArgument for Iso8601TimeSp {
195    // Unlike the macro'd formats, contains a space while still being one argument
196    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
197        let arg = self.to_string();
198        out.args_raw_nonempty(&arg.as_str());
199        Ok(())
200    }
201}
202
203#[cfg(feature = "hs-pow-full")]
204impl ItemArgument for tor_hscrypto::pow::v1::Seed {
205    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
206        let mut seed_bytes = vec![];
207        tor_bytes::Writer::write(&mut seed_bytes, &self)?;
208        out.add_arg(&Base64Unpadded::encode_string(&seed_bytes));
209        Ok(())
210    }
211}
212
213#[cfg(feature = "hs-pow-full")]
214impl ItemArgument for tor_hscrypto::pow::v1::Effort {
215    fn write_onto(&self, out: &mut ItemEncoder<'_>) -> Result<(), Bug> {
216        out.add_arg(&<Self as Into<u32>>::into(*self));
217        Ok(())
218    }
219}
220
221impl<'n> ItemEncoder<'n> {
222    /// Add a single argument.
223    ///
224    /// If the argument is not in the correct syntax, a `Bug`
225    /// error will be reported (later).
226    //
227    // This is not a hot path.  `dyn` for smaller code size.
228    pub(crate) fn arg(mut self, arg: &dyn ItemArgument) -> Self {
229        self.add_arg(arg);
230        self
231    }
232
233    /// Add a single argument, to a borrowed `ItemEncoder`
234    ///
235    /// If the argument is not in the correct syntax, a `Bug`
236    /// error will be reported (later).
237    //
238    // Needed for implementing `ItemArgument`
239    pub(crate) fn add_arg(&mut self, arg: &dyn ItemArgument) {
240        let () = arg
241            .write_onto(self)
242            .unwrap_or_else(|err| self.doc.built = Err(err));
243    }
244
245    /// Add zero or more arguments, supplied as a single string.
246    ///
247    /// `args` should zero or more valid argument strings,
248    /// separated by (single) spaces.
249    /// This is not (properly) checked.
250    /// Incorrect use might lead to malformed documents, or later errors.
251    #[allow(unused)] // TODO: We should eventually remove this if nothing starts to use it.
252    pub(crate) fn args_raw_string(mut self, args: &dyn Display) -> Self {
253        let args = args.to_string();
254        if !args.is_empty() {
255            self.args_raw_nonempty(&args);
256        }
257        self
258    }
259
260    /// Add one or more arguments, supplied as a single string, without any checking
261    fn args_raw_nonempty(&mut self, args: &dyn Display) {
262        self.doc.raw(&format_args!(" {}", args));
263    }
264
265    /// Add an object to the item
266    ///
267    /// Checks that `keywords` is in the correct syntax.
268    /// Doesn't check that it makes semantic sense for the position of the document.
269    /// `data` will be PEM (base64) encoded.
270    //
271    // If keyword is not in the correct syntax, a `Bug` is stored in self.doc.
272    pub(crate) fn object(
273        self,
274        keywords: &str,
275        // Writeable isn't dyn-compatible
276        data: impl tor_bytes::WriteableOnce,
277    ) {
278        use crate::parse::tokenize::object::*;
279
280        self.doc.write_with(|out| {
281            if keywords.is_empty() || !tag_keywords_ok(keywords) {
282                return Err(internal!("bad object keywords string {:?}", keywords));
283            }
284            let data = {
285                let mut bytes = vec![];
286                data.write_into(&mut bytes)?;
287                Base64::encode_string(&bytes)
288            };
289            let mut data = &data[..];
290            writeln!(out, "\n{BEGIN_STR}{keywords}{TAG_END}").expect("write!");
291            while !data.is_empty() {
292                let (l, r) = if data.len() > BASE64_PEM_MAX_LINE {
293                    data.split_at(BASE64_PEM_MAX_LINE)
294                } else {
295                    (data, "")
296                };
297                writeln!(out, "{l}").expect("write!");
298                data = r;
299            }
300            // final newline will be written by Drop impl
301            write!(out, "{END_STR}{keywords}{TAG_END}").expect("write!");
302            Ok(())
303        });
304    }
305}
306
307impl Drop for ItemEncoder<'_> {
308    fn drop(&mut self) {
309        self.doc.raw(&'\n');
310    }
311}
312
313/// A trait for building and signing netdocs.
314pub trait NetdocBuilder {
315    /// Build the document into textual form.
316    fn build_sign<R: RngCore + CryptoRng>(self, rng: &mut R) -> Result<String, EncodeError>;
317}
318
319#[cfg(test)]
320mod test {
321    // @@ begin test lint list maintained by maint/add_warning @@
322    #![allow(clippy::bool_assert_comparison)]
323    #![allow(clippy::clone_on_copy)]
324    #![allow(clippy::dbg_macro)]
325    #![allow(clippy::mixed_attributes_style)]
326    #![allow(clippy::print_stderr)]
327    #![allow(clippy::print_stdout)]
328    #![allow(clippy::single_char_pattern)]
329    #![allow(clippy::unwrap_used)]
330    #![allow(clippy::unchecked_duration_subtraction)]
331    #![allow(clippy::useless_vec)]
332    #![allow(clippy::needless_pass_by_value)]
333    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
334    use super::*;
335    use std::str::FromStr;
336
337    use crate::types::misc::Iso8601TimeNoSp;
338    use base64ct::{Base64Unpadded, Encoding};
339
340    #[test]
341    fn time_formats_as_args() {
342        use crate::doc::authcert::AuthCertKwd as ACK;
343        use crate::doc::netstatus::NetstatusKwd as NK;
344
345        let t_sp = Iso8601TimeSp::from_str("2020-04-18 08:36:57").unwrap();
346        let t_no_sp = Iso8601TimeNoSp::from_str("2021-04-18T08:36:57").unwrap();
347
348        let mut encode = NetdocEncoder::new();
349        encode.item(ACK::DIR_KEY_EXPIRES).arg(&t_sp);
350        encode
351            .item(NK::SHARED_RAND_PREVIOUS_VALUE)
352            .arg(&"3")
353            .arg(&"bMZR5Q6kBadzApPjd5dZ1tyLt1ckv1LfNCP/oyGhCXs=")
354            .arg(&t_no_sp);
355
356        let doc = encode.finish().unwrap();
357        println!("{}", doc);
358        assert_eq!(
359            doc,
360            r"dir-key-expires 2020-04-18 08:36:57
361shared-rand-previous-value 3 bMZR5Q6kBadzApPjd5dZ1tyLt1ckv1LfNCP/oyGhCXs= 2021-04-18T08:36:57
362"
363        );
364    }
365
366    #[test]
367    fn authcert() {
368        use crate::doc::authcert::AuthCertKwd as ACK;
369        use crate::doc::authcert::{AuthCert, UncheckedAuthCert};
370
371        // c&p from crates/tor-llcrypto/tests/testvec.rs
372        let pk_rsa = {
373            let pem = "
374MIGJAoGBANUntsY9boHTnDKKlM4VfczcBE6xrYwhDJyeIkh7TPrebUBBvRBGmmV+
375PYK8AM9irDtqmSR+VztUwQxH9dyEmwrM2gMeym9uXchWd/dt7En/JNL8srWIf7El
376qiBHRBGbtkF/Re5pb438HC/CGyuujp43oZ3CUYosJOfY/X+sD0aVAgMBAAE";
377            Base64Unpadded::decode_vec(&pem.replace('\n', "")).unwrap()
378        };
379
380        let mut encode = NetdocEncoder::new();
381        encode.item(ACK::DIR_KEY_CERTIFICATE_VERSION).arg(&3);
382        encode
383            .item(ACK::FINGERPRINT)
384            .arg(&"9367f9781da8eabbf96b691175f0e701b43c602e");
385        encode
386            .item(ACK::DIR_KEY_PUBLISHED)
387            .arg(&Iso8601TimeSp::from_str("2020-04-18 08:36:57").unwrap());
388        encode
389            .item(ACK::DIR_KEY_EXPIRES)
390            .arg(&Iso8601TimeSp::from_str("2021-04-18 08:36:57").unwrap());
391        encode
392            .item(ACK::DIR_IDENTITY_KEY)
393            .object("RSA PUBLIC KEY", &*pk_rsa);
394        encode
395            .item(ACK::DIR_SIGNING_KEY)
396            .object("RSA PUBLIC KEY", &*pk_rsa);
397        encode
398            .item(ACK::DIR_KEY_CROSSCERT)
399            .object("ID SIGNATURE", []);
400        encode
401            .item(ACK::DIR_KEY_CERTIFICATION)
402            .object("SIGNATURE", []);
403
404        let doc = encode.finish().unwrap();
405        eprintln!("{}", doc);
406        assert_eq!(
407            doc,
408            r"dir-key-certificate-version 3
409fingerprint 9367f9781da8eabbf96b691175f0e701b43c602e
410dir-key-published 2020-04-18 08:36:57
411dir-key-expires 2021-04-18 08:36:57
412dir-identity-key
413-----BEGIN RSA PUBLIC KEY-----
414MIGJAoGBANUntsY9boHTnDKKlM4VfczcBE6xrYwhDJyeIkh7TPrebUBBvRBGmmV+
415PYK8AM9irDtqmSR+VztUwQxH9dyEmwrM2gMeym9uXchWd/dt7En/JNL8srWIf7El
416qiBHRBGbtkF/Re5pb438HC/CGyuujp43oZ3CUYosJOfY/X+sD0aVAgMBAAE=
417-----END RSA PUBLIC KEY-----
418dir-signing-key
419-----BEGIN RSA PUBLIC KEY-----
420MIGJAoGBANUntsY9boHTnDKKlM4VfczcBE6xrYwhDJyeIkh7TPrebUBBvRBGmmV+
421PYK8AM9irDtqmSR+VztUwQxH9dyEmwrM2gMeym9uXchWd/dt7En/JNL8srWIf7El
422qiBHRBGbtkF/Re5pb438HC/CGyuujp43oZ3CUYosJOfY/X+sD0aVAgMBAAE=
423-----END RSA PUBLIC KEY-----
424dir-key-crosscert
425-----BEGIN ID SIGNATURE-----
426-----END ID SIGNATURE-----
427dir-key-certification
428-----BEGIN SIGNATURE-----
429-----END SIGNATURE-----
430"
431        );
432
433        let _: UncheckedAuthCert = AuthCert::parse(&doc).unwrap();
434    }
435}