Skip to main content

age_core/
format.rs

1//! Core types and encoding operations used by the age file format.
2
3use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
4use rand::{
5    distributions::{Distribution, Uniform},
6    thread_rng, RngCore,
7};
8use secrecy::{ExposeSecret, ExposeSecretMut, SecretBox};
9
10/// The prefix identifying an age stanza.
11const STANZA_TAG: &str = "-> ";
12
13/// The length of an age file key.
14pub const FILE_KEY_BYTES: usize = 16;
15
16/// A file key for encrypting or decrypting an age file.
17pub struct FileKey(SecretBox<[u8; FILE_KEY_BYTES]>);
18
19impl FileKey {
20    /// Creates a file key using a pre-boxed key.
21    pub fn new(file_key: Box<[u8; FILE_KEY_BYTES]>) -> Self {
22        Self(SecretBox::new(file_key))
23    }
24
25    /// Creates a file key using a function that can initialize the key in-place.
26    pub fn init_with_mut(ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES])) -> Self {
27        Self(SecretBox::init_with_mut(ctr))
28    }
29
30    /// Same as [`Self::init_with_mut`], but the constructor can be fallible.
31    pub fn try_init_with_mut<E>(
32        ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES]) -> Result<(), E>,
33    ) -> Result<Self, E> {
34        let mut file_key = SecretBox::new(Box::new([0; FILE_KEY_BYTES]));
35        ctr(file_key.expose_secret_mut())?;
36        Ok(Self(file_key))
37    }
38}
39
40impl ExposeSecret<[u8; FILE_KEY_BYTES]> for FileKey {
41    fn expose_secret(&self) -> &[u8; FILE_KEY_BYTES] {
42        self.0.expose_secret()
43    }
44}
45
46/// A section of the age header that encapsulates the file key as encrypted to a specific
47/// recipient.
48///
49/// This is the reference type; see [`Stanza`] for the owned type.
50#[derive(Debug)]
51pub struct AgeStanza<'a> {
52    /// A tag identifying this stanza type.
53    pub tag: &'a str,
54    /// Zero or more arguments.
55    pub args: Vec<&'a str>,
56    /// The body of the stanza, containing a wrapped [`FileKey`].
57    ///
58    /// Represented as the set of Base64-encoded lines for efficiency (so the caller can
59    /// defer the cost of decoding until the structure containing this stanza has been
60    /// fully-parsed).
61    body: Vec<&'a [u8]>,
62}
63
64impl<'a> AgeStanza<'a> {
65    /// Decodes and returns the body of this stanza.
66    pub fn body(&self) -> Vec<u8> {
67        // An AgeStanza will always contain at least one chunk.
68        let (partial_chunk, full_chunks) = self.body.split_last().unwrap();
69
70        // This is faster than collecting from a flattened iterator.
71        let mut data = vec![0; full_chunks.len() * 64 + partial_chunk.len()];
72        for (i, chunk) in full_chunks.iter().enumerate() {
73            // These chunks are guaranteed to be full by construction.
74            data[i * 64..(i + 1) * 64].copy_from_slice(chunk);
75        }
76        data[full_chunks.len() * 64..].copy_from_slice(partial_chunk);
77
78        // The chunks are guaranteed to contain Base64 characters by construction.
79        BASE64_STANDARD_NO_PAD.decode(&data).unwrap()
80    }
81}
82
83/// A section of the age header that encapsulates the file key as encrypted to a specific
84/// recipient.
85///
86/// This is the owned type; see [`AgeStanza`] for the reference type.
87#[derive(Debug, PartialEq, Eq)]
88pub struct Stanza {
89    /// A tag identifying this stanza type.
90    pub tag: String,
91    /// Zero or more arguments.
92    pub args: Vec<String>,
93    /// The body of the stanza, containing a wrapped [`FileKey`].
94    pub body: Vec<u8>,
95}
96
97impl From<AgeStanza<'_>> for Stanza {
98    fn from(stanza: AgeStanza<'_>) -> Self {
99        let body = stanza.body();
100        Stanza {
101            tag: stanza.tag.to_string(),
102            args: stanza.args.into_iter().map(|s| s.to_string()).collect(),
103            body,
104        }
105    }
106}
107
108/// Checks whether the string is a valid age "arbitrary string" (`1*VCHAR` in ABNF).
109pub fn is_arbitrary_string<S: AsRef<str>>(s: &S) -> bool {
110    let s = s.as_ref();
111    !s.is_empty()
112        && s.chars().all(|c| match u8::try_from(c) {
113            Ok(u) => (33..=126).contains(&u),
114            Err(_) => false,
115        })
116}
117
118/// Creates a random recipient stanza that exercises the joint in the age v1 format.
119///
120/// This function is guaranteed to return a valid stanza, but makes no other guarantees
121/// about the stanza's fields.
122pub fn grease_the_joint() -> Stanza {
123    // Generate arbitrary strings between 1 and 9 characters long.
124    fn gen_arbitrary_string<R: RngCore>(rng: &mut R) -> String {
125        let length = Uniform::from(1..9).sample(rng);
126        Uniform::from(33..=126)
127            .sample_iter(rng)
128            .map(char::from)
129            .take(length)
130            .collect()
131    }
132
133    let mut rng = thread_rng();
134
135    // Add a suffix to the random tag so users know what is going on.
136    let tag = format!("{}-grease", gen_arbitrary_string(&mut rng));
137
138    // Between this and the above generation bounds, the first line of the recipient
139    // stanza will be between eight and 66 characters.
140    let args = (0..Uniform::from(0..5).sample(&mut rng))
141        .map(|_| gen_arbitrary_string(&mut rng))
142        .collect();
143
144    // A length between 0 and 100 bytes exercises the following stanza bodies:
145    // - Empty
146    // - Single short-line
147    // - Single full-line
148    // - Two lines, second short
149    // - Two lines, both full
150    // - Three lines, last short
151    let mut body = vec![0; Uniform::from(0..100).sample(&mut rng)];
152    rng.fill_bytes(&mut body);
153
154    Stanza { tag, args, body }
155}
156
157/// Decoding operations for age types.
158pub mod read {
159    use nom::{
160        branch::alt,
161        bytes::streaming::{tag, take_while1, take_while_m_n},
162        character::streaming::newline,
163        combinator::{map, map_opt, opt, verify},
164        multi::{many_till, separated_list1},
165        sequence::{pair, preceded, terminated},
166        IResult, Parser,
167    };
168
169    use super::{AgeStanza, STANZA_TAG};
170
171    fn is_base64_char(c: u8) -> bool {
172        // Check against the ASCII values of the standard Base64 character set.
173        matches!(
174            c,
175            // A..=Z | a..=z | 0..=9 | + | /
176            65..=90 | 97..=122 | 48..=57 | 43 | 47,
177        )
178    }
179
180    /// Returns true if the byte is one of the specific ASCII values of the standard
181    /// Base64 character set which leave trailing bits when they occur as the last
182    /// character in an encoding of length 2 mod 4.
183    fn base64_has_no_trailing_bits_2(c: &u8) -> bool {
184        // With two trailing characters, the last character has up to four trailing bits.
185        matches!(
186            c,
187            // A | Q | g | w
188            65 | 81 | 103 | 119,
189        )
190    }
191
192    /// Returns true if the byte is one of the specific ASCII values of the standard
193    /// Base64 character set which leave trailing bits when they occur as the last
194    /// character in an encoding of length 3 mod 4.
195    fn base64_has_no_trailing_bits_3(c: &u8) -> bool {
196        // With three trailing characters, the last character has up to two trailing bits.
197        matches!(
198            c,
199            // A | E | I | M | Q | U | Y | c | g | k | o | s | w | 0 | 4 | 8
200            65 | 69 | 73 | 77 | 81 | 85 | 89 | 99 | 103 | 107 | 111 | 115 | 119 | 48 | 52 | 56,
201        )
202    }
203
204    /// Reads an age "arbitrary string".
205    ///
206    /// From the age specification:
207    /// ```text
208    /// ... an arbitrary string is a sequence of ASCII characters with values 33 to 126.
209    /// ```
210    pub fn arbitrary_string(input: &[u8]) -> IResult<&[u8], &str> {
211        map(take_while1(|c| (33..=126).contains(&c)), |bytes| {
212            // Safety: ASCII bytes are valid UTF-8
213            unsafe { std::str::from_utf8_unchecked(bytes) }
214        })
215        .parse(input)
216    }
217
218    fn wrapped_encoded_data(input: &[u8]) -> IResult<&[u8], Vec<&[u8]>> {
219        map(
220            many_till(
221                // Any body lines before the last MUST be full-length.
222                terminated(take_while_m_n(64, 64, is_base64_char), newline),
223                // Last body line:
224                // - MUST be short (empty if necessary).
225                // - MUST be a valid Base64 length (i.e. the length must not be 1 mod 4).
226                // - MUST NOT leave trailing bits (if the length is 2 or 3 mod 4).
227                verify(
228                    terminated(take_while_m_n(0, 63, is_base64_char), newline),
229                    |line: &[u8]| match line.len() % 4 {
230                        0 => true,
231                        1 => false,
232                        2 => base64_has_no_trailing_bits_2(line.last().unwrap()),
233                        3 => base64_has_no_trailing_bits_3(line.last().unwrap()),
234                        // No other cases, but Rust wants an exhaustive match on u8.
235                        _ => unreachable!(),
236                    },
237                ),
238            ),
239            |(full_chunks, partial_chunk): (Vec<&[u8]>, &[u8])| {
240                let mut chunks = full_chunks;
241                chunks.push(partial_chunk);
242                chunks
243            },
244        )
245        .parse(input)
246    }
247
248    fn legacy_wrapped_encoded_data(input: &[u8]) -> IResult<&[u8], Vec<&[u8]>> {
249        map_opt(
250            separated_list1(newline, take_while1(is_base64_char)),
251            |chunks: Vec<&[u8]>| {
252                // Enforce that the only chunk allowed to be shorter than 64 characters
253                // is the last chunk, and that its length must not be 1 mod 4.
254                let (partial_chunk, full_chunks) = chunks.split_last().unwrap();
255                if full_chunks.iter().any(|s| s.len() != 64)
256                    || partial_chunk.len() > 64
257                    || partial_chunk.len() % 4 == 1
258                    || (partial_chunk.len() % 4 == 2
259                        && !base64_has_no_trailing_bits_2(partial_chunk.last().unwrap()))
260                    || (partial_chunk.len() % 4 == 3
261                        && !base64_has_no_trailing_bits_3(partial_chunk.last().unwrap()))
262                {
263                    None
264                } else {
265                    Some(chunks)
266                }
267            },
268        )
269        .parse(input)
270    }
271
272    /// Reads an age stanza.
273    ///
274    /// From the age spec:
275    /// ```text
276    /// Each recipient stanza starts with a line beginning with -> and its type name,
277    /// followed by zero or more SP-separated arguments. The type name and the arguments
278    /// are arbitrary strings. Unknown recipient types are ignored. The rest of the
279    /// recipient stanza is a body of canonical base64 from RFC 4648 without padding
280    /// wrapped at exactly 64 columns.
281    /// ```
282    pub fn age_stanza(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
283        map(
284            pair(
285                preceded(
286                    tag(STANZA_TAG),
287                    terminated(separated_list1(tag(" "), arbitrary_string), newline),
288                ),
289                wrapped_encoded_data,
290            ),
291            |(mut args, body)| {
292                let tag = args.remove(0);
293                AgeStanza { tag, args, body }
294            },
295        )
296        .parse(input)
297    }
298
299    fn legacy_age_stanza_inner(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
300        map(
301            pair(
302                preceded(tag(STANZA_TAG), separated_list1(tag(" "), arbitrary_string)),
303                terminated(opt(preceded(newline, legacy_wrapped_encoded_data)), newline),
304            ),
305            |(mut args, body)| {
306                let tag = args.remove(0);
307                AgeStanza {
308                    tag,
309                    args,
310                    body: body.unwrap_or_else(|| vec![&[]]),
311                }
312            },
313        )
314        .parse(input)
315    }
316
317    /// Reads a age stanza, allowing the legacy encoding of an body.
318    ///
319    /// From the age spec:
320    /// ```text
321    /// Each recipient stanza starts with a line beginning with -> and its type name,
322    /// followed by zero or more SP-separated arguments. The type name and the arguments
323    /// are arbitrary strings. Unknown recipient types are ignored. The rest of the
324    /// recipient stanza is a body of canonical base64 from RFC 4648 without padding
325    /// wrapped at exactly 64 columns.
326    /// ```
327    ///
328    /// The spec was originally unclear about how to encode a stanza body. Both age and
329    /// rage implemented the encoding in a way such that a stanza with a body of length of
330    /// 0 mod 64 was indistinguishable from an incomplete stanza. The spec now requires a
331    /// stanza body to always be terminated with a short line (empty if necessary). This
332    /// API exists to handle files that include the legacy encoding. The only known
333    /// generator of 0 mod 64 bodies is [`grease_the_joint`], so this should only affect
334    /// age files encrypted with beta versions of the `age` or `rage` crates.
335    ///
336    /// [`grease_the_joint`]: super::grease_the_joint
337    pub fn legacy_age_stanza(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
338        alt((age_stanza, legacy_age_stanza_inner)).parse(input)
339    }
340
341    #[cfg(test)]
342    mod tests {
343        use super::*;
344
345        #[test]
346        fn base64_padding_rejected() {
347            assert!(wrapped_encoded_data(b"Tm8gcGFkZGluZyE\n").is_ok());
348            assert!(wrapped_encoded_data(b"Tm8gcGFkZGluZyE=\n").is_err());
349            // Internal padding is also rejected.
350            assert!(wrapped_encoded_data(b"SW50ZXJuYWwUGFk\n").is_ok());
351            assert!(wrapped_encoded_data(b"SW50ZXJuYWw=UGFk\n").is_err());
352        }
353    }
354}
355
356/// Encoding operations for age types.
357pub mod write {
358    use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
359    use cookie_factory::{
360        combinator::string,
361        multi::separated_list,
362        sequence::{pair, tuple},
363        SerializeFn, WriteContext,
364    };
365    use std::io::Write;
366    use std::iter;
367
368    use super::STANZA_TAG;
369
370    fn wrapped_encoded_data<'a, W: 'a + Write>(data: &[u8]) -> impl SerializeFn<W> + 'a {
371        let encoded = BASE64_STANDARD_NO_PAD.encode(data);
372
373        move |mut w: WriteContext<W>| {
374            let mut s = encoded.as_str();
375
376            // Write full body lines.
377            while s.len() >= 64 {
378                let (l, r) = s.split_at(64);
379                w = pair(string(l), string("\n"))(w)?;
380                s = r;
381            }
382
383            // Last body line MUST be short (empty if necessary).
384            pair(string(s), string("\n"))(w)
385        }
386    }
387
388    /// Writes an age stanza.
389    pub fn age_stanza<'a, W: 'a + Write, S: AsRef<str>>(
390        tag: &'a str,
391        args: &'a [S],
392        body: &'a [u8],
393    ) -> impl SerializeFn<W> + 'a {
394        pair(
395            tuple((
396                string(STANZA_TAG),
397                separated_list(
398                    string(" "),
399                    iter::once(tag)
400                        .chain(args.iter().map(|s| s.as_ref()))
401                        .map(string),
402                ),
403                string("\n"),
404            )),
405            wrapped_encoded_data(body),
406        )
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
413    use nom::error::ErrorKind;
414
415    use super::{read, write};
416
417    #[test]
418    fn parse_age_stanza() {
419        let test_tag = "X25519";
420        let test_args = &["CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20"];
421        let test_body = BASE64_STANDARD_NO_PAD
422            .decode("C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig")
423            .unwrap();
424
425        // The only body line is short, so we don't need a trailing empty line.
426        let test_stanza = "-> X25519 CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20
427C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig
428";
429
430        let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
431        assert_eq!(stanza.tag, test_tag);
432        assert_eq!(stanza.args, test_args);
433        assert_eq!(stanza.body(), test_body);
434
435        let mut buf = vec![];
436        cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, &test_body), &mut buf)
437            .unwrap();
438        assert_eq!(buf, test_stanza.as_bytes());
439    }
440
441    #[test]
442    fn age_stanza_with_empty_body() {
443        let test_tag = "empty-body";
444        let test_args = &["some", "arguments"];
445        let test_body = &[];
446
447        // The body is empty, so it is represented with an empty line.
448        let test_stanza = "-> empty-body some arguments
449
450";
451
452        let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
453        assert_eq!(stanza.tag, test_tag);
454        assert_eq!(stanza.args, test_args);
455        assert_eq!(stanza.body(), test_body);
456
457        let mut buf = vec![];
458        cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, test_body), &mut buf)
459            .unwrap();
460        assert_eq!(buf, test_stanza.as_bytes());
461    }
462
463    #[test]
464    fn age_stanza_with_full_body() {
465        let test_tag = "full-body";
466        let test_args = &["some", "arguments"];
467        let test_body = BASE64_STANDARD_NO_PAD
468            .decode("xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA")
469            .unwrap();
470
471        // The body fills a complete line, so it requires a trailing empty line.
472        let test_stanza = "-> full-body some arguments
473xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA
474
475";
476
477        let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
478        assert_eq!(stanza.tag, test_tag);
479        assert_eq!(stanza.args, test_args);
480        assert_eq!(stanza.body(), test_body);
481
482        let mut buf = vec![];
483        cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, &test_body), &mut buf)
484            .unwrap();
485        assert_eq!(buf, test_stanza.as_bytes());
486    }
487
488    #[test]
489    fn age_stanza_with_legacy_full_body() {
490        let test_tag = "full-body";
491        let test_args = &["some", "arguments"];
492        let test_body = BASE64_STANDARD_NO_PAD
493            .decode("xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA")
494            .unwrap();
495
496        // The body fills a complete line, but lacks a trailing empty line.
497        let test_stanza = "-> full-body some arguments
498xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA
499--- header end
500";
501
502        // The normal parser returns an error.
503        assert!(read::age_stanza(test_stanza.as_bytes()).is_err());
504
505        // We can parse with the legacy parser
506        let (_, stanza) = read::legacy_age_stanza(test_stanza.as_bytes()).unwrap();
507        assert_eq!(stanza.tag, test_tag);
508        assert_eq!(stanza.args, test_args);
509        assert_eq!(stanza.body(), test_body);
510    }
511
512    #[test]
513    fn age_stanza_invalid_last_line() {
514        // Artifact found by cargo-fuzz on commit 81f91581bf7e21075519dc23e4a28b4d201dd784
515        // We add an extra newline to the artifact so that we would "correctly" trigger
516        // the bug in the legacy part of `read::legacy_age_stanza`.
517        let artifact = "-> H
518/
519
520";
521
522        // The stanza parser requires the last body line is short (possibly empty), so
523        // should reject this artifact.
524        match read::age_stanza(artifact.as_bytes()) {
525            Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
526            Err(e) => panic!("Unexpected error: {}", e),
527            Ok((rest, stanza)) => {
528                assert_eq!(rest, b"\n");
529                // This is where the fuzzer triggered a panic.
530                let _ = stanza.body();
531                // We should never reach here either before or after the bug was fixed,
532                // because the body length is invalid.
533                panic!("Invalid test case was parsed without error");
534            }
535        }
536
537        // The legacy parser accepts this artifact by ignoring the invalid body line,
538        // because bodies were allowed to be omitted.
539        let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
540        // The remainder should the invalid body line. If the standard parser were fixed
541        // but the legacy parser was not, this would only contain a single newline.
542        assert_eq!(rest, b"/\n\n");
543        // This is where the fuzzer would have triggered a panic if it were using the
544        // legacy parser.
545        let body = stanza.body();
546        assert!(body.is_empty());
547    }
548
549    #[test]
550    fn age_stanza_last_line_two_trailing_chars() {
551        // Artifact found by cargo-fuzz on commit 8da15148fc005a48ffeb43eb76dab478eb2fdf72
552        // We add an extra newline to the artifact so that we would "correctly" trigger
553        // the bug in the legacy part of `read::legacy_age_stanza`.
554        let artifact = "-> '
555dy
556
557";
558
559        // The stanza parser requires the last body line is short (possibly empty), so
560        // should reject this artifact.
561        match read::age_stanza(artifact.as_bytes()) {
562            Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
563            Err(e) => panic!("Unexpected error: {}", e),
564            Ok((rest, stanza)) => {
565                assert_eq!(rest, b"\n");
566                // This is where the fuzzer triggered a panic.
567                let _ = stanza.body();
568                // We should never reach here either before or after the bug was fixed,
569                // because the last body line has trailing bits.
570                panic!("Invalid test case was parsed without error");
571            }
572        }
573
574        // The legacy parser accepts this artifact by ignoring the invalid body line,
575        // because bodies were allowed to be omitted.
576        let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
577        // The remainder should the invalid body line. If the standard parser were fixed
578        // but the legacy parser was not, this would only contain a single newline.
579        assert_eq!(rest, b"dy\n\n");
580        // This is where the fuzzer would have triggered a panic if it were using the
581        // legacy parser.
582        let body = stanza.body();
583        assert!(body.is_empty());
584    }
585
586    #[test]
587    fn age_stanza_last_line_three_trailing_chars() {
588        // Artifact found by cargo-fuzz after age_stanza_last_line_two_trailing_chars was
589        // incorrectly fixed.
590        let artifact = "-> h
591ddd
592
593";
594
595        // The stanza parser requires the last body line is short (possibly empty), so
596        // should reject this artifact.
597        match read::age_stanza(artifact.as_bytes()) {
598            Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
599            Err(e) => panic!("Unexpected error: {}", e),
600            Ok((rest, stanza)) => {
601                assert_eq!(rest, b"\n");
602                // This is where the fuzzer triggered a panic.
603                let _ = stanza.body();
604                // We should never reach here either before or after the bug was fixed,
605                // because the last body line has trailing bits.
606                panic!("Invalid test case was parsed without error");
607            }
608        }
609
610        // The legacy parser accepts this artifact by ignoring the invalid body line,
611        // because bodies were allowed to be omitted.
612        let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
613        // The remainder should the invalid body line. If the standard parser were fixed
614        // but the legacy parser was not, this would only contain a single newline.
615        assert_eq!(rest, b"ddd\n\n");
616        // This is where the fuzzer would have triggered a panic if it were using the
617        // legacy parser.
618        let body = stanza.body();
619        assert!(body.is_empty());
620    }
621}