Skip to main content

soroban_cli/config/
data.rs

1use crate::rpc::{GetTransactionResponse, GetTransactionResponseRaw, SimulateTransactionResponse};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::str::FromStr;
5use url::Url;
6
7use crate::utils::url::redact_url;
8use crate::xdr::{self, WriteXdr};
9
10#[derive(thiserror::Error, Debug)]
11pub enum Error {
12    #[error("Failed to find project directories")]
13    FailedToFindProjectDirs,
14    #[error(transparent)]
15    Io(#[from] std::io::Error),
16    #[error(transparent)]
17    SerdeJson(#[from] serde_json::Error),
18    #[error(transparent)]
19    InvalidUrl(#[from] url::ParseError),
20    #[error(transparent)]
21    Ulid(#[from] ulid::DecodeError),
22    #[error(transparent)]
23    Xdr(#[from] xdr::Error),
24}
25
26pub fn project_dir() -> Result<directories::ProjectDirs, Error> {
27    let dir = if let Ok(data_home) = std::env::var("STELLAR_DATA_HOME") {
28        ProjectDirs::from_path(std::path::PathBuf::from(data_home))
29    } else if let Ok(data_home) = std::env::var("XDG_DATA_HOME") {
30        ProjectDirs::from_path(std::path::PathBuf::from(data_home).join("stellar-cli"))
31    } else {
32        ProjectDirs::from("org", "stellar", "stellar-cli")
33    };
34
35    dir.ok_or(Error::FailedToFindProjectDirs)
36}
37
38#[allow(clippy::module_name_repetitions)]
39pub fn data_local_dir() -> Result<std::path::PathBuf, Error> {
40    Ok(project_dir()?.data_local_dir().to_path_buf())
41}
42
43pub fn actions_dir() -> Result<std::path::PathBuf, Error> {
44    let dir = data_local_dir()?.join("actions");
45    std::fs::create_dir_all(&dir)?;
46    Ok(dir)
47}
48
49pub fn spec_dir() -> Result<std::path::PathBuf, Error> {
50    let dir = data_local_dir()?.join("spec");
51    std::fs::create_dir_all(&dir)?;
52    Ok(dir)
53}
54
55pub fn bucket_dir() -> Result<std::path::PathBuf, Error> {
56    let dir = data_local_dir()?.join("bucket");
57    std::fs::create_dir_all(&dir)?;
58    Ok(dir)
59}
60
61pub fn write(action: Action, rpc_url: &Url) -> Result<ulid::Ulid, Error> {
62    let data = Data {
63        action,
64        rpc_url: redact_url(rpc_url.as_str()),
65    };
66    let id = ulid::Ulid::new();
67    let file = actions_dir()?.join(id.to_string()).with_extension("json");
68    crate::config::locator::write_hardened_file(&file, serde_json::to_string(&data)?.as_bytes())?;
69    Ok(id)
70}
71
72pub fn read(id: &ulid::Ulid) -> Result<(Action, Url), Error> {
73    let file = actions_dir()?.join(id.to_string()).with_extension("json");
74    let data: Data = serde_json::from_str(&std::fs::read_to_string(file)?)?;
75    Ok((data.action, Url::from_str(&data.rpc_url)?))
76}
77
78pub fn write_spec(hash: &str, spec_entries: &[xdr::ScSpecEntry]) -> Result<(), Error> {
79    let file = spec_dir()?.join(hash);
80    tracing::trace!("writing spec to {:?}", file);
81    let mut contents: Vec<u8> = Vec::new();
82    for entry in spec_entries {
83        contents.extend(entry.to_xdr(xdr::Limits::none())?);
84    }
85    crate::config::locator::write_hardened_file(&file, &contents)?;
86    Ok(())
87}
88
89pub fn read_spec(hash: &str) -> Result<Vec<xdr::ScSpecEntry>, Error> {
90    let file = spec_dir()?.join(hash);
91    tracing::trace!("reading spec from {:?}", file);
92    Ok(soroban_spec::read::parse_raw(&std::fs::read(file)?)?)
93}
94
95pub fn list_ulids() -> Result<Vec<ulid::Ulid>, Error> {
96    let dir = actions_dir()?;
97    let mut list = std::fs::read_dir(dir)?
98        .map(|entry| {
99            entry
100                .map(|e| e.file_name().into_string().unwrap())
101                .map_err(Error::from)
102        })
103        .collect::<Result<Vec<String>, Error>>()?;
104    list.sort();
105    Ok(list
106        .iter()
107        .map(|s| ulid::Ulid::from_str(s.trim_end_matches(".json")))
108        .collect::<Result<Vec<_>, _>>()?)
109}
110
111pub fn list_actions() -> Result<Vec<DatedAction>, Error> {
112    list_ulids()?
113        .into_iter()
114        .rev()
115        .map(|id| {
116            let (action, uri) = read(&id)?;
117            Ok(DatedAction(id, action, uri))
118        })
119        .collect::<Result<Vec<_>, Error>>()
120}
121
122pub struct DatedAction(ulid::Ulid, Action, Url);
123
124impl std::fmt::Display for DatedAction {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        let (id, a, uri) = (&self.0, &self.1, &self.2);
127        let datetime = to_datatime(id).format("%b %d %H:%M");
128        let status = match a {
129            Action::Simulate { response } => response
130                .error
131                .as_ref()
132                .map_or_else(|| "SUCCESS".to_string(), |_| "ERROR".to_string()),
133            Action::Send { response } => response.status.clone(),
134        };
135        write!(
136            f,
137            "{id} {} {status} {datetime} {} ",
138            a.type_str(),
139            redact_url(uri.as_str()),
140        )
141    }
142}
143
144impl DatedAction {}
145
146fn to_datatime(id: &ulid::Ulid) -> chrono::DateTime<chrono::Utc> {
147    chrono::DateTime::from_timestamp_millis(id.timestamp_ms().try_into().unwrap()).unwrap()
148}
149
150#[derive(Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152struct Data {
153    action: Action,
154    rpc_url: String,
155}
156
157#[derive(Serialize, Deserialize, Clone)]
158#[serde(rename_all = "snake_case")]
159pub enum Action {
160    Simulate {
161        response: SimulateTransactionResponse,
162    },
163    Send {
164        response: GetTransactionResponseRaw,
165    },
166}
167
168impl Action {
169    pub fn type_str(&self) -> String {
170        match self {
171            Action::Simulate { .. } => "Simulate",
172            Action::Send { .. } => "Send    ",
173        }
174        .to_string()
175    }
176}
177
178impl From<SimulateTransactionResponse> for Action {
179    fn from(response: SimulateTransactionResponse) -> Self {
180        Self::Simulate { response }
181    }
182}
183
184impl TryFrom<GetTransactionResponse> for Action {
185    type Error = xdr::Error;
186    fn try_from(res: GetTransactionResponse) -> Result<Self, Self::Error> {
187        Ok(Self::Send {
188            response: GetTransactionResponseRaw {
189                created_at: res.created_at,
190                fee_bump: res.fee_bump,
191                tx_hash: res.tx_hash,
192                application_order: res.application_order,
193                status: res.status,
194                ledger: res.ledger,
195                envelope_xdr: res.envelope.as_ref().map(to_xdr).transpose()?,
196                result_xdr: res.result.as_ref().map(to_xdr).transpose()?,
197                result_meta_xdr: res.result_meta.as_ref().map(to_xdr).transpose()?,
198                events: None,
199            },
200        })
201    }
202}
203
204fn to_xdr(data: &impl WriteXdr) -> Result<String, xdr::Error> {
205    data.to_xdr_base64(xdr::Limits::none())
206}
207
208#[cfg(test)]
209mod test {
210    use super::*;
211    use crate::test_utils::with_env_set;
212    use serial_test::serial;
213
214    #[test]
215    #[serial]
216    fn test_write_read() {
217        let t = assert_fs::TempDir::new().unwrap();
218        with_env_set("STELLAR_DATA_HOME", t.path(), || {
219            let rpc_uri = Url::from_str("http://localhost:8000").unwrap();
220            let sim = SimulateTransactionResponse::default();
221            let original_action: Action = sim.into();
222
223            let id = write(original_action.clone(), &rpc_uri.clone()).unwrap();
224            let (action, new_rpc_uri) = read(&id).unwrap();
225            assert_eq!(rpc_uri, new_rpc_uri);
226            match (action, original_action) {
227                (Action::Simulate { response: a }, Action::Simulate { response: b }) => {
228                    assert_eq!(a.min_resource_fee, b.min_resource_fee);
229                }
230                _ => panic!("Action mismatch"),
231            }
232        });
233    }
234
235    #[test]
236    #[serial]
237    fn actionlog_write_redacts_rpc_url_password_on_disk() {
238        let t = assert_fs::TempDir::new().unwrap();
239        with_env_set("STELLAR_DATA_HOME", t.path(), || {
240            let rpc_uri =
241                Url::from_str("https://alice:supersecret@rpc.example.com/soroban/rpc").unwrap();
242            let action: Action = SimulateTransactionResponse::default().into();
243
244            let id = write(action, &rpc_uri).unwrap();
245            let file = actions_dir()
246                .unwrap()
247                .join(id.to_string())
248                .with_extension("json");
249            let contents = std::fs::read_to_string(&file).unwrap();
250
251            assert!(
252                !contents.contains("supersecret"),
253                "password leaked into action-log JSON: {contents}"
254            );
255            assert!(
256                contents.contains("alice"),
257                "username should be preserved: {contents}"
258            );
259            assert!(
260                contents.contains("redacted"),
261                "expected literal `redacted` placeholder: {contents}"
262            );
263            assert!(
264                contents.contains("rpc.example.com"),
265                "expected host to be preserved: {contents}"
266            );
267        });
268    }
269
270    #[test]
271    #[serial]
272    fn actionlog_list_actions_renders_redacted_rpc_url() {
273        let t = assert_fs::TempDir::new().unwrap();
274        with_env_set("STELLAR_DATA_HOME", t.path(), || {
275            let rpc_uri =
276                Url::from_str("https://alice:supersecret@rpc.example.com/soroban/rpc").unwrap();
277            let action: Action = SimulateTransactionResponse::default().into();
278
279            write(action, &rpc_uri).unwrap();
280            let rendered = list_actions()
281                .unwrap()
282                .into_iter()
283                .map(|entry| entry.to_string())
284                .collect::<Vec<_>>()
285                .join("\n");
286
287            assert!(
288                !rendered.contains("supersecret"),
289                "password leaked into ls -l render: {rendered}"
290            );
291            assert!(
292                rendered.contains("alice:redacted"),
293                "expected `alice:redacted` in ls -l render: {rendered}"
294            );
295        });
296    }
297}