Skip to main content

stellar_xdr/cli/generate/
arbitrary.rs

1use arbitrary::Unstructured;
2use clap::{Args, ValueEnum};
3use std::{
4    ffi::{OsStr, OsString},
5    fs,
6    io::{stdin, stdout, Read, Write},
7    str::FromStr,
8};
9
10use crate::cli::util;
11
12#[derive(thiserror::Error, Debug)]
13pub enum Error {
14    #[error("unknown type {0}, choose one of {1:?}")]
15    UnknownType(String, &'static [&'static str]),
16    #[error("error reading file: {0}")]
17    ReadFile(#[from] std::io::Error),
18    #[error("error generating XDR: {0}")]
19    WriteXdr(#[from] crate::Error),
20    #[error("error generating JSON: {0}")]
21    GenerateJson(#[from] serde_json::Error),
22    #[error("error generating arbitrary value: {0}")]
23    Arbitrary(#[from] arbitrary::Error),
24    #[error("type doesn't have a text representation, use 'json' as output")]
25    TextUnsupported,
26    #[error("could not generate a value whose JSON contains all of {hints:?} within {attempts} attempts")]
27    HintNotFound { hints: Vec<String>, attempts: u64 },
28}
29
30/// Generate arbitrary XDR values
31#[derive(Args, Debug, Clone)]
32#[command()]
33pub struct Cmd {
34    /// XDR type to generate
35    #[arg(long)]
36    pub r#type: String,
37
38    // Output format to encode to
39    #[arg(long = "output", value_enum, default_value_t)]
40    pub output_format: OutputFormat,
41
42    /// Keep generating values until the value's JSON representation contains
43    /// this string. Useful for hinting toward a particular union/enum variant
44    /// or sub-value. Repeat the flag to require several substrings; all of
45    /// them must be present in the same value.
46    #[arg(long)]
47    pub hint: Vec<String>,
48
49    /// Maximum number of generation attempts when --hint is set before giving
50    /// up.
51    #[arg(long, default_value_t = 20_000)]
52    pub hint_attempts: u64,
53
54    /// Drive generation with the bytes in this file, or stdin if '-', instead
55    /// of with fresh randomness. Bytes are consumed the same way the fuzz
56    /// targets consume them, so a fuzz corpus entry piped in here produces the
57    /// value that entry stands for.
58    #[arg(long, conflicts_with = "hint")]
59    pub entropy: Option<OsString>,
60}
61
62#[derive(Default, Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum)]
63pub enum OutputFormat {
64    Single,
65    #[default]
66    SingleBase64,
67    // TODO: Stream,
68    // TODO: StreamBase64,
69    // TODO: StreamFramed,
70    Json,
71    JsonFormatted,
72    Text,
73}
74
75// TODO: Remove run_x macro, it exists only to reduce the diff from when curr/next
76// channels existed and each had their own run_curr/run_next invocation.
77macro_rules! run_x {
78    ($f:ident) => {
79        fn $f(&self) -> Result<(), Error> {
80            use crate::WriteXdr;
81            let r#type = crate::TypeVariant::from_str(&self.r#type).map_err(|_| {
82                Error::UnknownType(self.r#type.clone(), &crate::TypeVariant::VARIANTS_STR)
83            })?;
84            let (v, hint_json) = self.generate(r#type)?;
85            match self.output_format {
86                OutputFormat::Single => {
87                    let l = crate::Limits::none();
88                    stdout().write_all(&v.to_xdr(l)?)?
89                }
90                OutputFormat::SingleBase64 => {
91                    let l = crate::Limits::none();
92                    println!("{}", v.to_xdr_base64(l)?)
93                }
94                OutputFormat::Json => match hint_json {
95                    Some(json) => println!("{json}"),
96                    None => println!("{}", serde_json::to_string(&v)?),
97                },
98                OutputFormat::JsonFormatted => {
99                    println!("{}", serde_json::to_string_pretty(&v)?);
100                }
101                OutputFormat::Text => {
102                    let v = serde_json::to_value(v)?;
103                    let text = util::serde_json_value_to_text(v).ok_or(Error::TextUnsupported)?;
104                    println!("{text}")
105                }
106            }
107            Ok(())
108        }
109    };
110}
111
112impl Cmd {
113    /// Run the CLIs generate arbitrary command.
114    ///
115    /// ## Errors
116    ///
117    /// If the command is configured with state that is invalid.
118    pub fn run(&self) -> Result<(), Error> {
119        self.run_inner()
120    }
121
122    run_x!(run_inner);
123
124    /// Generate a single arbitrary value of the given type from fresh random
125    /// bytes.
126    fn generate_one(type_: crate::TypeVariant) -> Result<crate::Type, Error> {
127        let r = rand::random::<[u8; 10_240]>();
128        let mut u = Unstructured::new(&r);
129        Ok(crate::Type::arbitrary(type_, &mut u)?)
130    }
131
132    /// Read the bytes that drive generation, from a file or from stdin if the
133    /// path is `-`.
134    fn read_entropy(path: &OsStr) -> Result<Vec<u8>, Error> {
135        if path == "-" {
136            let mut bytes = Vec::new();
137            stdin().read_to_end(&mut bytes)?;
138            Ok(bytes)
139        } else {
140            Ok(fs::read(path)?)
141        }
142    }
143
144    /// Generate an arbitrary value of the given type.
145    ///
146    /// If `entropy` is configured the value is built from those bytes rather
147    /// than from randomness, which makes the output a function of the input
148    /// alone. Hints are meaningless there — the bytes decide the value — and
149    /// the two flags conflict.
150    ///
151    /// If any `hint`s are configured, values are generated repeatedly (up to
152    /// `hint_attempts` times) until one whose JSON representation contains all
153    /// of the hints is found. In that case the matching value's compact JSON,
154    /// already computed to test the hints, is returned alongside the value so
155    /// callers can reuse it instead of serializing again. When no hints are
156    /// configured no JSON is computed and `None` is returned.
157    ///
158    /// An attempt that fails to generate or serialize is treated as unlucky
159    /// rather than fatal: it is skipped and the search continues with fresh
160    /// randomness, so a single bad draw doesn't abort the whole search.
161    fn generate(&self, type_: crate::TypeVariant) -> Result<(crate::Type, Option<String>), Error> {
162        if let Some(path) = &self.entropy {
163            let bytes = Self::read_entropy(path)?;
164            let mut u = Unstructured::new(&bytes);
165            return Ok((crate::Type::arbitrary(type_, &mut u)?, None));
166        }
167        if self.hint.is_empty() {
168            return Ok((Self::generate_one(type_)?, None));
169        }
170        for _ in 0..self.hint_attempts {
171            let Ok(v) = Self::generate_one(type_) else {
172                continue;
173            };
174            let Ok(json) = serde_json::to_string(&v) else {
175                continue;
176            };
177            if self.hint.iter().all(|hint| json.contains(hint)) {
178                return Ok((v, Some(json)));
179            }
180        }
181        Err(Error::HintNotFound {
182            hints: self.hint.clone(),
183            attempts: self.hint_attempts,
184        })
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// The same bytes must always produce the same value, otherwise piping a
193    /// corpus entry in wouldn't show what that entry stands for.
194    #[test]
195    fn entropy_is_deterministic() {
196        let file = std::env::temp_dir().join("stellar-xdr-arbitrary-entropy");
197        fs::write(&file, (0u8..=255).collect::<Vec<u8>>()).unwrap();
198        let cmd = Cmd {
199            r#type: "TimeBounds".to_string(),
200            output_format: OutputFormat::Json,
201            hint: vec![],
202            hint_attempts: 1,
203            entropy: Some(file.clone().into_os_string()),
204        };
205        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
206        let (first, json) = cmd.generate(type_).unwrap();
207        assert!(json.is_none());
208        let (second, _) = cmd.generate(type_).unwrap();
209        assert_eq!(
210            serde_json::to_string(&first).unwrap(),
211            serde_json::to_string(&second).unwrap(),
212        );
213        fs::remove_file(&file).unwrap();
214    }
215
216    #[test]
217    fn hint_matches() {
218        let cmd = Cmd {
219            r#type: "TimeBounds".to_string(),
220            output_format: OutputFormat::Json,
221            hint: vec!["min_time".into()],
222            hint_attempts: 1000,
223            entropy: None,
224        };
225        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
226        let (v, json) = cmd.generate(type_).unwrap();
227        let json = json.expect("a hinted search returns the computed json");
228        assert!(json.contains("min_time"));
229        assert_eq!(json, serde_json::to_string(&v).unwrap());
230    }
231
232    #[test]
233    fn all_hints_match() {
234        let cmd = Cmd {
235            r#type: "TimeBounds".to_string(),
236            output_format: OutputFormat::Json,
237            hint: vec!["min_time".into(), "max_time".into()],
238            hint_attempts: 1000,
239            entropy: None,
240        };
241        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
242        let (_v, json) = cmd.generate(type_).unwrap();
243        let json = json.expect("a hinted search returns the computed json");
244        assert!(json.contains("min_time"));
245        assert!(json.contains("max_time"));
246    }
247
248    #[test]
249    fn hint_not_found() {
250        let cmd = Cmd {
251            r#type: "TimeBounds".to_string(),
252            output_format: OutputFormat::Json,
253            hint: vec!["zzz_does_not_exist_xyzzy".into()],
254            hint_attempts: 5,
255            entropy: None,
256        };
257        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
258        assert!(matches!(
259            cmd.generate(type_),
260            Err(Error::HintNotFound { .. })
261        ));
262    }
263
264    #[test]
265    fn not_all_hints_found() {
266        let cmd = Cmd {
267            r#type: "TimeBounds".to_string(),
268            output_format: OutputFormat::Json,
269            hint: vec!["min_time".into(), "zzz_does_not_exist_xyzzy".into()],
270            hint_attempts: 5,
271            entropy: None,
272        };
273        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
274        assert!(matches!(
275            cmd.generate(type_),
276            Err(Error::HintNotFound { .. })
277        ));
278    }
279
280    #[test]
281    fn no_hint_generates_once() {
282        let cmd = Cmd {
283            r#type: "TimeBounds".to_string(),
284            output_format: OutputFormat::Json,
285            hint: vec![],
286            hint_attempts: 1,
287            entropy: None,
288        };
289        let type_ = crate::TypeVariant::from_str("TimeBounds").unwrap();
290        let (_v, json) = cmd.generate(type_).unwrap();
291        assert!(json.is_none());
292    }
293}