Skip to main content

soroban_spec_tools/
contract.rs

1use base64::{engine::general_purpose::STANDARD as base64, Engine as _};
2use std::{
3    fmt::Display,
4    io::{self, Cursor},
5};
6
7use stellar_xdr::{
8    self as xdr, Limited, Limits, ReadXdr, ScEnvMetaEntry, ScEnvMetaEntryInterfaceVersion,
9    ScMetaEntry, ScMetaV0, ScSpecEntry, ScSpecFunctionV0, ScSpecUdtEnumV0, ScSpecUdtErrorEnumV0,
10    ScSpecUdtStructV0, ScSpecUdtUnionV0, StringM, WriteXdr,
11};
12
13/// Maximum recursion depth allowed when decoding contract spec/meta sections.
14///
15/// These sections come from attacker-authored contract WASM, and `ScSpecTypeDef`
16/// is a recursive XDR type, so decoding with `Limits::none()` (depth `u32::MAX`)
17/// lets a deeply-nested type definition exhaust the stack and abort the process.
18/// 500 matches `soroban-env-host`'s `DEFAULT_XDR_RW_LIMITS`, so any spec the
19/// network would accept still decodes, while deeper input fails with a clean
20/// `DepthLimitExceeded` error instead of a crash.
21const SPEC_XDR_DEPTH_LIMIT: u32 = 500;
22
23pub struct Spec {
24    pub env_meta_base64: Option<String>,
25    pub env_meta: Vec<ScEnvMetaEntry>,
26    pub meta_base64: Option<String>,
27    pub meta: Vec<ScMetaEntry>,
28    pub spec_base64: Option<String>,
29    pub spec: Vec<ScSpecEntry>,
30}
31
32#[derive(thiserror::Error, Debug)]
33pub enum Error {
34    #[error("reading file {filepath}: {error}")]
35    CannotReadContractFile {
36        filepath: std::path::PathBuf,
37        error: io::Error,
38    },
39    #[error("cannot parse wasm file {file}: {error}")]
40    CannotParseWasm {
41        file: std::path::PathBuf,
42        error: wasmparser::BinaryReaderError,
43    },
44    #[error("xdr processing error: {0}")]
45    Xdr(#[from] xdr::Error),
46
47    #[error(transparent)]
48    Parser(#[from] wasmparser::BinaryReaderError),
49}
50
51impl Spec {
52    pub fn new(bytes: &[u8]) -> Result<Self, Error> {
53        let mut env_meta: Option<Vec<u8>> = None;
54        let mut meta: Option<Vec<u8>> = None;
55        let mut spec: Option<Vec<u8>> = None;
56        for payload in wasmparser::Parser::new(0).parse_all(bytes) {
57            let payload = payload?;
58            if let wasmparser::Payload::CustomSection(section) = payload {
59                let out = match section.name() {
60                    "contractenvmetav0" => &mut env_meta,
61                    "contractmetav0" => &mut meta,
62                    "contractspecv0" => &mut spec,
63                    _ => continue,
64                };
65
66                if let Some(existing_data) = out {
67                    let combined_data = [existing_data, section.data()].concat();
68                    *out = Some(combined_data);
69                } else {
70                    *out = Some(section.data().to_vec());
71                }
72            }
73        }
74
75        let mut env_meta_base64 = None;
76        let env_meta = if let Some(env_meta) = env_meta {
77            env_meta_base64 = Some(base64.encode(&env_meta));
78            let cursor = Cursor::new(env_meta);
79            let mut read = Limited::new(cursor, Limits::depth(SPEC_XDR_DEPTH_LIMIT));
80            ScEnvMetaEntry::read_xdr_iter(&mut read).collect::<Result<Vec<_>, xdr::Error>>()?
81        } else {
82            vec![]
83        };
84
85        let mut meta_base64 = None;
86        let meta = if let Some(meta) = meta {
87            meta_base64 = Some(base64.encode(&meta));
88            let cursor = Cursor::new(meta);
89            let mut depth_limit_read = Limited::new(cursor, Limits::depth(SPEC_XDR_DEPTH_LIMIT));
90            ScMetaEntry::read_xdr_iter(&mut depth_limit_read)
91                .collect::<Result<Vec<_>, xdr::Error>>()?
92        } else {
93            vec![]
94        };
95
96        let (spec_base64, spec) = if let Some(spec) = spec {
97            let (spec_base64, spec) = Spec::spec_to_base64(&spec)?;
98            (Some(spec_base64), spec)
99        } else {
100            (None, vec![])
101        };
102
103        Ok(Spec {
104            env_meta_base64,
105            env_meta,
106            meta_base64,
107            meta,
108            spec_base64,
109            spec,
110        })
111    }
112
113    pub fn spec_as_json_array(&self) -> Result<String, Error> {
114        let spec = self
115            .spec
116            .iter()
117            .map(|e| {
118                Ok(format!(
119                    "\"{}\"",
120                    e.to_xdr_base64(Limits::depth(SPEC_XDR_DEPTH_LIMIT))?
121                ))
122            })
123            .collect::<Result<Vec<_>, Error>>()?
124            .join(",\n");
125        Ok(format!("[{spec}]"))
126    }
127
128    pub fn spec_to_base64(spec: &[u8]) -> Result<(String, Vec<ScSpecEntry>), Error> {
129        let spec_base64 = base64.encode(spec);
130        let cursor = Cursor::new(spec);
131        let mut read = Limited::new(cursor, Limits::depth(SPEC_XDR_DEPTH_LIMIT));
132        Ok((
133            spec_base64,
134            ScSpecEntry::read_xdr_iter(&mut read).collect::<Result<Vec<_>, xdr::Error>>()?,
135        ))
136    }
137}
138
139impl Display for Spec {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        if let Some(env_meta) = &self.env_meta_base64 {
142            writeln!(f, "Env Meta: {env_meta}")?;
143            for env_meta_entry in &self.env_meta {
144                match env_meta_entry {
145                    ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(
146                        ScEnvMetaEntryInterfaceVersion {
147                            protocol,
148                            pre_release,
149                        },
150                    ) => {
151                        writeln!(f, " • Protocol Version: {protocol}")?;
152                        if pre_release != &0 {
153                            writeln!(f, " • Pre-release Version: {pre_release})")?;
154                        }
155                    }
156                }
157            }
158            writeln!(f)?;
159        } else {
160            writeln!(f, "Env Meta: None\n")?;
161        }
162
163        if let Some(_meta) = &self.meta_base64 {
164            writeln!(f, "Contract Meta:")?;
165            for meta_entry in &self.meta {
166                match meta_entry {
167                    ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) => {
168                        writeln!(
169                            f,
170                            " • {}: {}",
171                            sanitize(&key.to_utf8_string_lossy()),
172                            sanitize(&val.to_utf8_string_lossy())
173                        )?;
174                    }
175                }
176            }
177            writeln!(f)?;
178        } else {
179            writeln!(f, "Contract Meta: None\n")?;
180        }
181
182        if let Some(_spec_base64) = &self.spec_base64 {
183            writeln!(f, "Contract Spec:")?;
184            for spec_entry in &self.spec {
185                match spec_entry {
186                    ScSpecEntry::FunctionV0(func) => write_func(f, func)?,
187                    ScSpecEntry::UdtUnionV0(udt) => write_union(f, udt)?,
188                    ScSpecEntry::UdtStructV0(udt) => write_struct(f, udt)?,
189                    ScSpecEntry::UdtEnumV0(udt) => write_enum(f, udt)?,
190                    ScSpecEntry::UdtErrorEnumV0(udt) => write_error(f, udt)?,
191                    ScSpecEntry::EventV0(_) => {}
192                }
193            }
194        } else {
195            writeln!(f, "Contract Spec: None")?;
196        }
197        Ok(())
198    }
199}
200
201fn write_func(f: &mut std::fmt::Formatter<'_>, func: &ScSpecFunctionV0) -> std::fmt::Result {
202    writeln!(
203        f,
204        " • Function: {}",
205        sanitize(&func.name.to_utf8_string_lossy())
206    )?;
207    if !func.doc.is_empty() {
208        writeln!(
209            f,
210            "     Docs: {}",
211            indent(&sanitize(&func.doc.to_utf8_string_lossy()), 11).trim()
212        )?;
213    }
214    writeln!(
215        f,
216        "     Inputs: {}",
217        indent(&format!("{:#?}", func.inputs), 5).trim()
218    )?;
219    writeln!(
220        f,
221        "     Output: {}",
222        indent(&format!("{:#?}", func.outputs), 5).trim()
223    )?;
224    writeln!(f)?;
225    Ok(())
226}
227
228fn write_union(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtUnionV0) -> std::fmt::Result {
229    writeln!(f, " • Union: {}", format_name(&udt.lib, &udt.name))?;
230    if !udt.doc.is_empty() {
231        writeln!(
232            f,
233            "     Docs: {}",
234            indent(&sanitize(&udt.doc.to_utf8_string_lossy()), 10).trim()
235        )?;
236    }
237    writeln!(f, "     Cases:")?;
238    for case in &udt.cases {
239        writeln!(f, "      • {}", indent(&format!("{case:#?}"), 8).trim())?;
240    }
241    writeln!(f)?;
242    Ok(())
243}
244
245fn write_struct(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtStructV0) -> std::fmt::Result {
246    writeln!(f, " • Struct: {}", format_name(&udt.lib, &udt.name))?;
247    if !udt.doc.is_empty() {
248        writeln!(
249            f,
250            "     Docs: {}",
251            indent(&sanitize(&udt.doc.to_utf8_string_lossy()), 10).trim()
252        )?;
253    }
254    writeln!(f, "     Fields:")?;
255    for field in &udt.fields {
256        writeln!(
257            f,
258            "      • {}: {}",
259            sanitize(&field.name.to_utf8_string_lossy()),
260            indent(&format!("{:#?}", field.type_), 8).trim()
261        )?;
262        if !field.doc.is_empty() {
263            writeln!(f, "{}", indent(&format!("{:#?}", field.doc), 8))?;
264        }
265    }
266    writeln!(f)?;
267    Ok(())
268}
269
270fn write_enum(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtEnumV0) -> std::fmt::Result {
271    writeln!(f, " • Enum: {}", format_name(&udt.lib, &udt.name))?;
272    if !udt.doc.is_empty() {
273        writeln!(
274            f,
275            "     Docs: {}",
276            indent(&sanitize(&udt.doc.to_utf8_string_lossy()), 10).trim()
277        )?;
278    }
279    writeln!(f, "     Cases:")?;
280    for case in &udt.cases {
281        writeln!(f, "      • {}", indent(&format!("{case:#?}"), 8).trim())?;
282    }
283    writeln!(f)?;
284    Ok(())
285}
286
287fn write_error(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtErrorEnumV0) -> std::fmt::Result {
288    writeln!(f, " • Error: {}", format_name(&udt.lib, &udt.name))?;
289    if !udt.doc.is_empty() {
290        writeln!(
291            f,
292            "     Docs: {}",
293            indent(&sanitize(&udt.doc.to_utf8_string_lossy()), 10).trim()
294        )?;
295    }
296    writeln!(f, "     Cases:")?;
297    for case in &udt.cases {
298        writeln!(f, "      • {}", indent(&format!("{case:#?}"), 8).trim())?;
299    }
300    writeln!(f)?;
301    Ok(())
302}
303
304pub fn sanitize(s: &str) -> String {
305    escape_bytes::escape(s.as_bytes())
306        .into_iter()
307        .map(char::from)
308        .collect()
309}
310
311fn indent(s: &str, n: usize) -> String {
312    let pad = " ".repeat(n);
313    s.lines()
314        .map(|line| format!("{pad}{line}"))
315        .collect::<Vec<_>>()
316        .join("\n")
317}
318
319fn format_name(lib: &StringM<80>, name: &StringM<60>) -> String {
320    if lib.is_empty() {
321        sanitize(&name.to_utf8_string_lossy())
322    } else {
323        format!(
324            "{}::{}",
325            sanitize(&lib.to_utf8_string_lossy()),
326            sanitize(&name.to_utf8_string_lossy())
327        )
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::borrow::Cow;
335    use stellar_xdr::{ScSpecFunctionV0, ScSpecTypeDef, ScSpecTypeOption};
336
337    /// Wraps `spec` bytes in a minimal WASM module's `contractspecv0` section.
338    fn wasm_with_spec(spec: &[u8]) -> Vec<u8> {
339        let mut module = wasm_encoder::Module::new();
340        module.section(&wasm_encoder::CustomSection {
341            name: Cow::Borrowed("contractspecv0"),
342            data: Cow::Borrowed(spec),
343        });
344        module.finish()
345    }
346
347    /// A spec entry for a function whose single output has type `type_`.
348    fn fn_entry_returning(type_: ScSpecTypeDef) -> ScSpecEntry {
349        ScSpecEntry::FunctionV0(ScSpecFunctionV0 {
350            outputs: vec![type_].try_into().unwrap(),
351            ..Default::default()
352        })
353    }
354
355    /// A contract spec whose type nesting exceeds the decoder's depth limit must
356    /// fail with a clean error rather than aborting the process via stack
357    /// exhaustion. See `SPEC_XDR_DEPTH_LIMIT`.
358    #[test]
359    fn deeply_nested_spec_type_is_rejected() {
360        // `ScSpecTypeDef::Option` boxes another `ScSpecTypeDef`, so decoding it
361        // recurses once per level. Nest well past the limit.
362        let mut type_ = ScSpecTypeDef::Bool;
363        for _ in 0..(SPEC_XDR_DEPTH_LIMIT + 100) {
364            type_ = ScSpecTypeDef::Option(Box::new(ScSpecTypeOption {
365                value_type: Box::new(type_),
366            }));
367        }
368        let entry = fn_entry_returning(type_);
369        // Encode without limits so the crafted bytes reflect an attacker's WASM;
370        // the guard under test is on the decode side.
371        let spec = entry.to_xdr(Limits::none()).unwrap();
372        let wasm = wasm_with_spec(&spec);
373
374        match Spec::new(&wasm) {
375            Err(Error::Xdr(xdr::Error::DepthLimitExceeded)) => {}
376            Err(e) => panic!("expected DepthLimitExceeded, got error {e:?}"),
377            Ok(_) => panic!("expected DepthLimitExceeded, but the spec decoded"),
378        }
379    }
380
381    /// A normally-nested spec still decodes successfully under the depth limit.
382    #[test]
383    fn shallow_spec_type_is_accepted() {
384        let type_ = ScSpecTypeDef::Option(Box::new(ScSpecTypeOption {
385            value_type: Box::new(ScSpecTypeDef::Bool),
386        }));
387        let entry = fn_entry_returning(type_);
388        let spec = entry.to_xdr(Limits::none()).unwrap();
389        let wasm = wasm_with_spec(&spec);
390
391        let parsed = Spec::new(&wasm).expect("shallow spec should decode");
392        assert_eq!(parsed.spec, vec![entry]);
393    }
394}