Skip to main content

soroban_cli/commands/contract/
read.rs

1use std::{
2    fmt::Debug,
3    io::{self, stdout},
4};
5
6use crate::xdr::{
7    ContractDataEntry, Error as XdrError, LedgerEntryData, LedgerKey, LedgerKeyContractData,
8    Limits, ScVal, WriteXdr,
9};
10use clap::{Parser, ValueEnum};
11
12use crate::utils::XDR_DEPTH_LIMIT;
13use crate::{
14    config::{self, locator},
15    key,
16    rpc::{self, FullLedgerEntries, FullLedgerEntry},
17};
18
19#[derive(Parser, Debug, Clone)]
20#[group(skip)]
21pub struct Cmd {
22    /// Type of output to generate
23    #[arg(long, value_enum, default_value("string"))]
24    pub output: Output,
25    #[command(flatten)]
26    pub key: key::Args,
27    #[command(flatten)]
28    config: config::ArgsLocatorAndNetwork,
29}
30
31#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum)]
32pub enum Output {
33    /// String
34    String,
35    /// Json
36    Json,
37    /// XDR
38    Xdr,
39}
40
41#[derive(thiserror::Error, Debug)]
42pub enum Error {
43    #[error("parsing key {key}: {error}")]
44    CannotParseKey {
45        key: String,
46        error: soroban_spec_tools::Error,
47    },
48    #[error("parsing XDR key {key}: {error}")]
49    CannotParseXdrKey { key: String, error: XdrError },
50    #[error("cannot parse contract ID {contract_id}: {error}")]
51    CannotParseContractId {
52        contract_id: String,
53        error: stellar_strkey::DecodeError,
54    },
55    #[error("cannot print result {result:?}: {error}")]
56    CannotPrintResult {
57        result: ScVal,
58        error: soroban_spec_tools::Error,
59    },
60    #[error("cannot print result {result:?}: {error}")]
61    CannotPrintJsonResult {
62        result: ScVal,
63        error: serde_json::Error,
64    },
65    #[error("cannot print as csv: {error}")]
66    CannotPrintAsCsv { error: csv::Error },
67    #[error("cannot print: {error}")]
68    CannotPrintFlush { error: io::Error },
69    #[error(transparent)]
70    Config(#[from] config::Error),
71    #[error("either `--key` or `--key-xdr` are required when querying a network")]
72    KeyIsRequired,
73    #[error(transparent)]
74    Rpc(#[from] rpc::Error),
75    #[error(transparent)]
76    Xdr(#[from] XdrError),
77    #[error("no matching contract data entries were found for the specified contract id")]
78    NoContractDataEntryFoundForContractID,
79    #[error(transparent)]
80    Key(#[from] key::Error),
81    #[error("Only contract data and code keys are allowed")]
82    OnlyDataAllowed,
83    #[error(transparent)]
84    Locator(#[from] locator::Error),
85    #[error(transparent)]
86    Network(#[from] config::network::Error),
87}
88
89impl Cmd {
90    pub async fn run(&self) -> Result<(), Error> {
91        let entries = self
92            .execute(&config::Args {
93                locator: self.config.locator.clone(),
94                network: self.config.network.clone(),
95                source_account: config::UnresolvedMuxedAccount::default(),
96                sign_with: config::sign_with::Args::default(),
97                fee: None,
98                inclusion_fee: None,
99            })
100            .await?;
101        self.output_entries(&entries)
102    }
103
104    pub async fn execute(&self, config: &config::Args) -> Result<FullLedgerEntries, Error> {
105        let network = config.get_network()?;
106        tracing::trace!(?network);
107        let client = network.rpc_client()?;
108        let keys = self.key.parse_keys(&config.locator, &network)?;
109        Ok(client.get_full_ledger_entries(&keys).await?)
110    }
111
112    fn output_entries(&self, entries: &FullLedgerEntries) -> Result<(), Error> {
113        if entries.entries.is_empty() {
114            return Err(Error::NoContractDataEntryFoundForContractID);
115        }
116        tracing::trace!("{entries:#?}");
117        let mut out = csv::Writer::from_writer(stdout());
118        for entry in &entries.entries {
119            out.write_record(Self::entry_record(self.output, entry)?)
120                .map_err(|e| Error::CannotPrintAsCsv { error: e })?;
121        }
122        out.flush()
123            .map_err(|e| Error::CannotPrintFlush { error: e })?;
124        Ok(())
125    }
126
127    fn entry_record(output: Output, entry: &FullLedgerEntry) -> Result<[String; 4], Error> {
128        let FullLedgerEntry {
129            key,
130            val,
131            live_until_ledger_seq,
132            last_modified_ledger,
133        } = entry;
134        let (
135            LedgerKey::ContractData(LedgerKeyContractData { key, .. }),
136            LedgerEntryData::ContractData(ContractDataEntry { val, .. }),
137        ) = &(key, val)
138        else {
139            return Err(Error::OnlyDataAllowed);
140        };
141        let output = match output {
142            // `to_string` returns raw, unescaped bytes for a top-level
143            // `ScVal::Symbol`, so sanitize before writing to the terminal to
144            // strip any control/escape sequences the entry may carry.
145            Output::String => [
146                soroban_spec_tools::sanitize(&soroban_spec_tools::to_string(key).map_err(|e| {
147                    Error::CannotPrintResult {
148                        result: key.clone(),
149                        error: e,
150                    }
151                })?),
152                soroban_spec_tools::sanitize(&soroban_spec_tools::to_string(val).map_err(|e| {
153                    Error::CannotPrintResult {
154                        result: val.clone(),
155                        error: e,
156                    }
157                })?),
158                last_modified_ledger.to_string(),
159                live_until_ledger_seq.unwrap_or_default().to_string(),
160            ],
161            Output::Json => [
162                serde_json::to_string_pretty(&key).map_err(|error| {
163                    Error::CannotPrintJsonResult {
164                        result: key.clone(),
165                        error,
166                    }
167                })?,
168                serde_json::to_string_pretty(&val).map_err(|error| {
169                    Error::CannotPrintJsonResult {
170                        result: val.clone(),
171                        error,
172                    }
173                })?,
174                serde_json::to_string_pretty(&last_modified_ledger).map_err(|error| {
175                    Error::CannotPrintJsonResult {
176                        result: val.clone(),
177                        error,
178                    }
179                })?,
180                serde_json::to_string_pretty(&live_until_ledger_seq.unwrap_or_default()).map_err(
181                    |error| Error::CannotPrintJsonResult {
182                        result: val.clone(),
183                        error,
184                    },
185                )?,
186            ],
187            Output::Xdr => [
188                key.to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))?,
189                val.to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))?,
190                last_modified_ledger.to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))?,
191                live_until_ledger_seq
192                    .unwrap_or_default()
193                    .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))?,
194            ],
195        };
196        Ok(output)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::xdr::{
204        ContractDataDurability, ContractId, ExtensionPoint, Hash, ScAddress, ScSymbol, StringM,
205    };
206
207    fn symbol(bytes: &[u8]) -> ScVal {
208        let s: StringM<32> = bytes.to_vec().try_into().unwrap();
209        ScVal::Symbol(ScSymbol(s))
210    }
211
212    fn contract_data_entry(key: ScVal, val: ScVal) -> FullLedgerEntry {
213        let contract = ScAddress::Contract(ContractId(Hash([0u8; 32])));
214        FullLedgerEntry {
215            key: LedgerKey::ContractData(LedgerKeyContractData {
216                contract: contract.clone(),
217                key,
218                durability: ContractDataDurability::Persistent,
219            }),
220            val: LedgerEntryData::ContractData(ContractDataEntry {
221                ext: ExtensionPoint::V0,
222                contract,
223                key: ScVal::Void,
224                durability: ContractDataDurability::Persistent,
225                val,
226            }),
227            last_modified_ledger: 2026,
228            live_until_ledger_seq: Some(3_000_000),
229        }
230    }
231
232    // A top-level `ScVal::Symbol` is rendered by `to_string` as raw, unescaped
233    // bytes. The default (`string`) output must not let those control/escape
234    // sequences reach the terminal.
235    #[test]
236    fn string_output_strips_control_bytes_from_symbol() {
237        let attack = b"\x1b[2J\x1b[Hpwned";
238        let entry = contract_data_entry(symbol(attack), symbol(attack));
239
240        let record = Cmd::entry_record(Output::String, &entry).unwrap();
241
242        assert!(
243            !record[0].as_bytes().contains(&0x1b),
244            "key field leaked an ESC byte: {:?}",
245            record[0]
246        );
247        assert!(
248            !record[1].as_bytes().contains(&0x1b),
249            "val field leaked an ESC byte: {:?}",
250            record[1]
251        );
252        // The (escaped) payload text is still present, just inert.
253        assert!(record[0].contains("pwned"));
254    }
255}