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