tor_netdoc/
encode.rs

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