Skip to main content

rain_metadata/cli/
build.rs

1use clap::Parser;
2use anyhow::anyhow;
3use itertools::izip;
4use std::path::PathBuf;
5use crate::cli::output::SupportedOutputEncoding;
6use crate::meta::{
7    RainMetaDocumentV1Item, KnownMeta, ContentType, ContentEncoding, ContentLanguage,
8    magic::KnownMagic,
9};
10
11/// command for building rain meta
12#[derive(Parser)]
13pub struct Build {
14    /// Output path. If not specified, the output is written to stdout.
15    #[arg(short, long)]
16    output_path: Option<PathBuf>,
17    /// Output encoding. If not specified, the output is written in binary format.
18    #[arg(short = 'E', long, default_value = "binary")]
19    output_encoding: SupportedOutputEncoding,
20    /// Global magic number. If not specified, the default magic number is used.
21    /// The default magic number is rain-meta-document-v1. Don't change this
22    /// unless you know what you are doing.
23    #[arg(short = 'M', long, default_value = "rain-meta-document-v1")]
24    global_magic: KnownMagic,
25    /// Sequence of input paths. The number of input paths must match the number
26    /// of magic numbers, content types, content encodings and content languages.
27    /// Reading from stdin is not supported but proccess substitution can be used.
28    #[arg(short, long, num_args = 1..)]
29    input_path: Vec<PathBuf>,
30    /// Sequence of magic numbers. The number of magic numbers must match the
31    /// number of input paths, content types, content encodings and content languages.
32    /// Magic numbers are arbitrary byte sequences used to build self-describing
33    /// payloads.
34    #[arg(short, long, num_args = 1..)]
35    magic: Vec<KnownMagic>,
36    /// Sequence of content types. The number of content types must match the
37    /// number of input paths, magic numbers, content encodings and content languages.
38    /// Content type is as per http headers.
39    #[arg(short = 't', long, num_args = 1..)]
40    content_type: Vec<ContentType>,
41    /// Sequence of content encodings. The number of content encodings must match the
42    /// number of input paths, magic numbers, content types and content languages.
43    /// Content encoding is as per http headers.
44    #[arg(short = 'e', long, num_args = 1..)]
45    content_encoding: Vec<ContentEncoding>,
46    /// Sequence of content languages. The number of content languages must match the
47    /// number of input paths, magic numbers, content types and content encodings.
48    /// Content language is as per http headers.
49    #[arg(short = 'l', long, num_args = 1..)]
50    content_language: Vec<ContentLanguage>,
51}
52
53/// Temporary housing for raw data before it is converted into a RainMetaDocumentV1Item.
54#[derive(Clone, Debug)]
55pub struct BuildItem {
56    /// Raw data. Ostensibly this is the content of a file.
57    pub data: Vec<u8>,
58    /// Magic number taken from build options.
59    pub magic: KnownMagic,
60    /// Content type taken from build options.
61    pub content_type: ContentType,
62    /// Content encoding taken from build options.
63    pub content_encoding: ContentEncoding,
64    /// Content language taken from build options.
65    pub content_language: ContentLanguage,
66}
67
68/// Moving from a BuildItem to a RainMetaDocumentV1Item requires normalization
69/// according to the magic number and encoding from the build options.
70impl TryFrom<&BuildItem> for RainMetaDocumentV1Item {
71    type Error = anyhow::Error;
72    fn try_from(item: &BuildItem) -> anyhow::Result<Self> {
73        let normalized = TryInto::<KnownMeta>::try_into(item.magic)?.normalize(&item.data)?;
74        let encoded = item.content_encoding.encode(&normalized);
75        Ok(RainMetaDocumentV1Item {
76            payload: serde_bytes::ByteBuf::from(encoded),
77            magic: item.magic,
78            content_type: item.content_type,
79            content_encoding: item.content_encoding,
80            content_language: item.content_language,
81            schema: None,
82        })
83    }
84}
85
86/// Build a rain meta document from a sequence of BuildItems.
87pub fn build_bytes(magic: KnownMagic, items: Vec<BuildItem>) -> anyhow::Result<Vec<u8>> {
88    let mut metas: Vec<RainMetaDocumentV1Item> = vec![];
89    for item in items {
90        metas.push(RainMetaDocumentV1Item::try_from(&item)?);
91    }
92    Ok(RainMetaDocumentV1Item::cbor_encode_seq(&metas, magic)?)
93}
94
95/// Build a rain meta document from command line options.
96/// Enforces length constraints on the input paths, magic numbers, content types,
97/// content encodings and content languages.
98/// Handles reading input files and writing to files/stdout according to the
99/// build options.
100pub fn build(b: Build) -> anyhow::Result<()> {
101    if b.input_path.len() != b.magic.len() {
102        return Err(anyhow!(
103            "{} inputs does not match {} magic numbers.",
104            b.input_path.len(),
105            b.magic.len()
106        ));
107    }
108
109    if b.input_path.len() != b.content_type.len() {
110        return Err(anyhow!(
111            "{} inputs does not match {} content types.",
112            b.input_path.len(),
113            b.content_type.len()
114        ));
115    }
116
117    if b.input_path.len() != b.content_encoding.len() {
118        return Err(anyhow!(
119            "{} inputs does not match {} content encodings.",
120            b.input_path.len(),
121            b.content_encoding.len()
122        ));
123    }
124
125    if b.input_path.len() != b.content_language.len() {
126        return Err(anyhow!(
127            "{} inputs does not match {} content languages.",
128            b.input_path.len(),
129            b.content_language.len()
130        ));
131    }
132    let mut items: Vec<BuildItem> = vec![];
133    for (input_path, magic, content_type, content_encoding, content_language) in izip!(
134        b.input_path.iter(),
135        b.magic.iter(),
136        b.content_type.iter(),
137        b.content_encoding.iter(),
138        b.content_language.iter()
139    ) {
140        items.push(BuildItem {
141            data: std::fs::read(input_path)?,
142            magic: *magic,
143            content_type: *content_type,
144            content_encoding: *content_encoding,
145            content_language: *content_language,
146        });
147    }
148    crate::cli::output::output(
149        &b.output_path,
150        b.output_encoding,
151        &build_bytes(b.global_magic, items)?,
152    )
153}
154
155#[cfg(all(test, not(target_family = "wasm")))]
156mod tests {
157    use strum::IntoEnumIterator;
158    use crate::meta::{
159        magic::{self, KnownMagic},
160        ContentType, ContentEncoding, ContentLanguage, RainMetaDocumentV1Item,
161    };
162    use super::BuildItem;
163    use super::build_bytes;
164    use crate::meta::types::authoring::v1::AuthoringMeta;
165
166    const AUTHORING_META_V1_JSON: &str = r#"[{"word":"stack","description":"Copies an existing value from the stack.","operandParserOffset":16}]"#;
167
168    /// Test that the magic number prefix is correct for all known magic numbers
169    /// in isolation from all build items.
170    #[test]
171    fn test_build_empty() -> anyhow::Result<()> {
172        for global_magic in magic::KnownMagic::iter() {
173            let built_bytes = build_bytes(global_magic, vec![])?;
174            assert_eq!(built_bytes, global_magic.to_prefix_bytes());
175        }
176        Ok(())
177    }
178
179    /// We can build a single document item from a single build item. A
180    /// dotrain-v1 payload passes through normalisation untouched, so this
181    /// tests the item construction and nothing else.
182    #[test]
183    fn test_into_meta_document() -> anyhow::Result<()> {
184        let build_item = BuildItem {
185            data: "[]".as_bytes().to_vec(),
186            magic: KnownMagic::DotrainV1,
187            content_type: ContentType::Json,
188            content_encoding: ContentEncoding::None,
189            content_language: ContentLanguage::En,
190        };
191
192        let meta_document = RainMetaDocumentV1Item::try_from(&build_item)?;
193        let expected_meta_document = RainMetaDocumentV1Item {
194            payload: serde_bytes::ByteBuf::from("[]".as_bytes().to_vec()),
195            magic: KnownMagic::DotrainV1,
196            content_type: ContentType::Json,
197            content_encoding: ContentEncoding::None,
198            content_language: ContentLanguage::En,
199            schema: None,
200        };
201        assert_eq!(meta_document, expected_meta_document);
202        Ok(())
203    }
204
205    /// The final CBOR bytes are as expected for a single json content type
206    /// item. A dotrain-v1 payload passes through normalisation untouched, so
207    /// the asserted bytes are all envelope.
208    #[test]
209    fn test_empty_item() -> anyhow::Result<()> {
210        let build_item = BuildItem {
211            data: "[]".as_bytes().to_vec(),
212            magic: KnownMagic::DotrainV1,
213            content_type: ContentType::Json,
214            content_encoding: ContentEncoding::Identity,
215            content_language: ContentLanguage::En,
216        };
217
218        let bytes = super::build_bytes(KnownMagic::RainMetaDocumentV1, vec![build_item.clone()])?;
219
220        // https://github.com/rainprotocol/specs/blob/main/metadata-v1.md#example
221        // 8 byte magic number prefix
222        assert_eq!(
223            &bytes[0..8],
224            KnownMagic::RainMetaDocumentV1.to_prefix_bytes()
225        );
226        // cbor map with 5 keys
227        assert_eq!(bytes[8], 0xa5);
228        // key 0
229        assert_eq!(bytes[9], 0x00);
230        // major type 2 (bytes) length 2
231        assert_eq!(bytes[10], 0b010_00010);
232        // payload
233        assert_eq!(bytes[11..13], "[]".as_bytes()[..]);
234        // key 1
235        assert_eq!(bytes[13], 0x01);
236        // major type 0 (unsigned integer) value 27
237        assert_eq!(bytes[14], 0b000_11011);
238        // magic number
239        assert_eq!(&bytes[15..23], KnownMagic::DotrainV1.to_prefix_bytes());
240        // key 2
241        assert_eq!(bytes[23], 0x02);
242        // text string application/json length 16
243        assert_eq!(bytes[24], 0b011_10000);
244        // the string application/json
245        assert_eq!(&bytes[25..41], "application/json".as_bytes());
246        // key 3
247        assert_eq!(bytes[41], 0x03);
248        // text string identity length 8
249        assert_eq!(bytes[42], 0b011_01000);
250        // the string identity
251        assert_eq!(&bytes[43..51], "identity".as_bytes());
252        // key 4
253        assert_eq!(bytes[51], 0x04);
254        // text string en length 2
255        assert_eq!(bytes[52], 0b011_00010);
256        // the string en
257        assert_eq!(&bytes[53..55], "en".as_bytes());
258
259        assert_eq!(bytes.len(), 55);
260
261        Ok(())
262    }
263
264    #[test]
265    fn test_cbor_encoding_type() -> anyhow::Result<()> {
266        let build_item = BuildItem {
267            data: "[]".as_bytes().to_vec(),
268            magic: KnownMagic::DotrainV1,
269            content_type: ContentType::Cbor,
270            content_encoding: ContentEncoding::Identity,
271            content_language: ContentLanguage::En,
272        };
273
274        let bytes = super::build_bytes(KnownMagic::RainMetaDocumentV1, vec![build_item.clone()])?;
275
276        // https://github.com/rainprotocol/specs/blob/main/metadata-v1.md#example
277        // 8 byte magic number prefix
278        assert_eq!(
279            &bytes[0..8],
280            KnownMagic::RainMetaDocumentV1.to_prefix_bytes()
281        );
282        // cbor map with 5 keys
283        assert_eq!(bytes[8], 0xa5);
284        // key 0
285        assert_eq!(bytes[9], 0x00);
286        // major type 2 (bytes) length 2
287        assert_eq!(bytes[10], 0b010_00010);
288        // payload
289        assert_eq!(bytes[11..13], "[]".as_bytes()[..]);
290        // key 1
291        assert_eq!(bytes[13], 0x01);
292        // major type 0 (unsigned integer) value 27
293        assert_eq!(bytes[14], 0b000_11011);
294        // magic number
295        assert_eq!(&bytes[15..23], KnownMagic::DotrainV1.to_prefix_bytes());
296        // key 2
297        assert_eq!(bytes[23], 0x02);
298        // text string application/cbor length 16
299        assert_eq!(bytes[24], 0b011_10000);
300        // the string application/cbor
301        assert_eq!(&bytes[25..41], "application/cbor".as_bytes());
302        // key 3
303        assert_eq!(bytes[41], 0x03);
304        // text string identity length 8
305        assert_eq!(bytes[42], 0b011_01000);
306        // the string identity
307        assert_eq!(&bytes[43..51], "identity".as_bytes());
308        // key 4
309        assert_eq!(bytes[51], 0x04);
310        // text string en length 2
311        assert_eq!(bytes[52], 0b011_00010);
312        // the string en
313        assert_eq!(&bytes[53..55], "en".as_bytes());
314
315        assert_eq!(bytes.len(), 55);
316
317        Ok(())
318    }
319
320    use clap::Parser;
321    use std::io::Write;
322    use super::{Build, build};
323
324    /// Conversion normalizes the payload for the item's magic and then
325    /// applies the content encoding.
326    #[test]
327    fn test_item_normalize_then_encode() -> anyhow::Result<()> {
328        // Json authoring meta normalizes to its abi encoding, then deflates.
329        let build_item = BuildItem {
330            data: AUTHORING_META_V1_JSON.as_bytes().to_vec(),
331            magic: KnownMagic::AuthoringMetaV1,
332            content_type: ContentType::Json,
333            content_encoding: ContentEncoding::Deflate,
334            content_language: ContentLanguage::En,
335        };
336        let meta_document = RainMetaDocumentV1Item::try_from(&build_item)?;
337        let abi =
338            serde_json::from_str::<AuthoringMeta>(AUTHORING_META_V1_JSON)?.abi_encode_validate()?;
339        assert_ne!(abi, build_item.data);
340        assert_eq!(
341            meta_document.payload.as_ref(),
342            ContentEncoding::Deflate.encode(&abi)
343        );
344
345        // Un-normalizable data is rejected.
346        let invalid_item = BuildItem {
347            data: "not json".as_bytes().to_vec(),
348            ..build_item
349        };
350        assert!(RainMetaDocumentV1Item::try_from(&invalid_item).is_err());
351        Ok(())
352    }
353
354    fn parse_build(args: &[&str]) -> Build {
355        Build::try_parse_from(args).unwrap()
356    }
357
358    /// Each arity guard fires with its own message, before any file IO:
359    /// the input path never exists and yet the mismatch is what errors.
360    #[test]
361    fn test_build_arity_guards() {
362        let b = parse_build(&[
363            "build",
364            "-i",
365            "does-not-exist.json",
366            "-m",
367            "authoring-meta-v1",
368            "-m",
369            "authoring-meta-v1",
370        ]);
371        assert_eq!(
372            build(b).unwrap_err().to_string(),
373            "1 inputs does not match 2 magic numbers."
374        );
375
376        let b = parse_build(&[
377            "build",
378            "-i",
379            "does-not-exist.json",
380            "-m",
381            "authoring-meta-v1",
382            "-t",
383            "json",
384            "-t",
385            "json",
386        ]);
387        assert_eq!(
388            build(b).unwrap_err().to_string(),
389            "1 inputs does not match 2 content types."
390        );
391
392        let b = parse_build(&[
393            "build",
394            "-i",
395            "does-not-exist.json",
396            "-m",
397            "authoring-meta-v1",
398            "-t",
399            "json",
400            "-e",
401            "identity",
402            "-e",
403            "identity",
404        ]);
405        assert_eq!(
406            build(b).unwrap_err().to_string(),
407            "1 inputs does not match 2 content encodings."
408        );
409
410        let b = parse_build(&[
411            "build",
412            "-i",
413            "does-not-exist.json",
414            "-m",
415            "authoring-meta-v1",
416            "-t",
417            "json",
418            "-e",
419            "identity",
420            "-l",
421            "en",
422            "-l",
423            "en",
424        ]);
425        assert_eq!(
426            build(b).unwrap_err().to_string(),
427            "1 inputs does not match 2 content languages."
428        );
429    }
430
431    /// build() reads each input file, builds the document under the
432    /// global magic and writes it to the output path; the hex output
433    /// encoding is honored.
434    #[test]
435    fn test_build_reads_files_and_encodes_output() -> anyhow::Result<()> {
436        let mut input = tempfile::NamedTempFile::new()?;
437        input.write_all(AUTHORING_META_V1_JSON.as_bytes())?;
438        let output = tempfile::NamedTempFile::new()?;
439
440        let expected = build_bytes(
441            KnownMagic::RainMetaDocumentV1,
442            vec![BuildItem {
443                data: AUTHORING_META_V1_JSON.as_bytes().to_vec(),
444                magic: KnownMagic::AuthoringMetaV1,
445                content_type: ContentType::Json,
446                content_encoding: ContentEncoding::Identity,
447                content_language: ContentLanguage::En,
448            }],
449        )?;
450
451        let input_path = input.path().to_str().unwrap().to_string();
452        let output_path = output.path().to_str().unwrap().to_string();
453
454        let b = parse_build(&[
455            "build",
456            "-i",
457            &input_path,
458            "-m",
459            "authoring-meta-v1",
460            "-t",
461            "json",
462            "-e",
463            "identity",
464            "-l",
465            "en",
466            "-o",
467            &output_path,
468        ]);
469        build(b)?;
470        assert_eq!(std::fs::read(output.path())?, expected);
471
472        let b = parse_build(&[
473            "build",
474            "-i",
475            &input_path,
476            "-m",
477            "authoring-meta-v1",
478            "-t",
479            "json",
480            "-e",
481            "identity",
482            "-l",
483            "en",
484            "-o",
485            &output_path,
486            "-E",
487            "hex",
488        ]);
489        build(b)?;
490        assert_eq!(
491            std::fs::read_to_string(output.path())?,
492            alloy::primitives::hex::encode_prefixed(&expected)
493        );
494        Ok(())
495    }
496}