Skip to main content

soroban_cli/commands/tx/
edit.rs

1use std::{
2    env,
3    fs::{self},
4    io::{stdin, Cursor, IsTerminal},
5    path::PathBuf,
6    process::{self},
7};
8
9use tempfile::TempDir;
10
11use serde_json::json;
12
13use crate::{commands::global, print::Print};
14
15fn schema_url() -> String {
16    let ver = stellar_xdr::VERSION.pkg;
17    format!("https://stellar.org/schema/xdr-json/v{ver}/TransactionEnvelope.json")
18}
19
20#[derive(thiserror::Error, Debug)]
21pub enum Error {
22    #[error(transparent)]
23    Io(#[from] std::io::Error),
24
25    #[error(transparent)]
26    StellarXdr(#[from] stellar_xdr::Error),
27
28    #[error(transparent)]
29    SerdeJson(#[from] serde_json::Error),
30
31    #[error(transparent)]
32    Base64Decode(#[from] base64::DecodeError),
33
34    #[error("Editor returned non-zero status")]
35    EditorNonZeroStatus,
36}
37
38// Command to edit the transaction
39/// e.g. `stellar tx new manage-data --data-name hello --build-only | stellar tx edit`
40#[derive(Debug, clap::Parser, Clone, Default)]
41#[group(skip)]
42pub struct Cmd {}
43
44impl Cmd {
45    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
46        let print = Print::new(global_args.quiet);
47        let json: String = if stdin().is_terminal() {
48            default_json()
49        } else {
50            let mut input = String::new();
51            stdin().read_line(&mut input)?;
52            let input = input.trim();
53            xdr_to_json::<stellar_xdr::TransactionEnvelope>(input)?
54        };
55
56        let (_temp_dir, path) = tmp_file(&json)?;
57        let editor = get_editor();
58
59        print.infoln(format!("Editing transaction at {}", path.display()));
60        open_editor(&print, &editor, &path)?;
61
62        let contents = fs::read_to_string(&path)?;
63        let xdr = json_to_xdr::<stellar_xdr::TransactionEnvelope>(&contents)?;
64
65        println!("{xdr}");
66
67        Ok(())
68    }
69}
70
71struct Editor {
72    cmd: String,
73    source: String,
74    args: Vec<String>,
75}
76
77fn tmp_file(contents: &str) -> Result<(TempDir, PathBuf), Error> {
78    let temp_dir = tempfile::Builder::new()
79        .prefix("stellar-tx-edit-")
80        .tempdir()?;
81    let path = temp_dir.path().join("edit.json");
82
83    #[cfg(unix)]
84    {
85        use std::os::unix::fs::PermissionsExt;
86        fs::set_permissions(temp_dir.path(), fs::Permissions::from_mode(0o700))?;
87    }
88
89    crate::config::locator::write_hardened_file(&path, contents.as_bytes())?;
90
91    Ok((temp_dir, path))
92}
93
94fn get_editor() -> Editor {
95    let (source, cmd) = env::var("STELLAR_EDITOR")
96        .map(|val| ("STELLAR_EDITOR", val))
97        .or_else(|_| env::var("EDITOR").map(|val| ("EDITOR", val)))
98        .or_else(|_| env::var("VISUAL").map(|val| ("VISUAL", val)))
99        .unwrap_or_else(|_| ("default", "vim".to_string()));
100
101    let parts: Vec<&str> = cmd.split_whitespace().collect();
102    let cmd = parts[0].to_string();
103    let args = &parts[1..]
104        .iter()
105        .map(|&s| s.to_string())
106        .collect::<Vec<String>>();
107
108    Editor {
109        source: source.to_string(),
110        cmd,
111        args: args.clone(),
112    }
113}
114
115fn open_editor(print: &Print, editor: &Editor, path: &PathBuf) -> Result<(), Error> {
116    print.infoln(format!(
117        "Opening editor with `{source}=\"{cmd}\"`...",
118        source = editor.source,
119        cmd = editor.cmd,
120    ));
121
122    let mut binding = process::Command::new(editor.cmd.clone());
123    let command = binding.args(editor.args.clone()).arg(path);
124
125    // Windows doesn't have devices like /dev/tty.
126    #[cfg(unix)]
127    {
128        use fs::File;
129        let tty = File::open("/dev/tty")?;
130        let tty_out = fs::OpenOptions::new().write(true).open("/dev/tty")?;
131        let tty_err = fs::OpenOptions::new().write(true).open("/dev/tty")?;
132
133        command
134            .stdin(tty)
135            .stdout(tty_out)
136            .stderr(tty_err)
137            .env("TERM", "xterm-256color");
138    }
139
140    let status = command.spawn()?.wait()?;
141
142    if status.success() {
143        Ok(())
144    } else {
145        Err(Error::EditorNonZeroStatus)
146    }
147}
148
149fn xdr_to_json<T>(xdr_string: &str) -> Result<String, Error>
150where
151    T: stellar_xdr::ReadXdr + serde::Serialize,
152{
153    let tx = T::from_xdr_base64(xdr_string, stellar_xdr::Limits::none())?;
154    let mut schema: serde_json::Value = serde_json::to_value(tx)?;
155    schema["$schema"] = json!(schema_url());
156    let json = serde_json::to_string_pretty(&schema)?;
157
158    Ok(json)
159}
160
161fn json_to_xdr<T>(json_string: &str) -> Result<String, Error>
162where
163    T: serde::de::DeserializeOwned + stellar_xdr::WriteXdr,
164{
165    let mut schema: serde_json::Value = serde_json::from_str(json_string)?;
166
167    if let Some(obj) = schema.as_object_mut() {
168        obj.remove("$schema");
169    }
170
171    let json_string = serde_json::to_string(&schema)?;
172
173    let value: T = serde_json::from_str(json_string.as_str())?;
174    let mut data = Vec::new();
175    let cursor = Cursor::new(&mut data);
176    let mut limit = stellar_xdr::Limited::new(cursor, stellar_xdr::Limits::none());
177    value.write_xdr(&mut limit)?;
178
179    Ok(value.to_xdr_base64(stellar_xdr::Limits::none())?)
180}
181
182fn default_json() -> String {
183    let schema_url = schema_url();
184    format!(
185        r#"{{
186  "$schema": "{schema_url}",
187  "tx": {{
188    "tx": {{
189      "source_account": "",
190      "fee": 100,
191      "seq_num": 0,
192      "cond": "none",
193      "memo": "none",
194      "operations": [],
195      "ext": "v0"
196    }},
197    "signatures": []
198  }}
199}}
200"#
201    )
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn tmp_file_uses_private_tempdir() {
210        let contents = r#"{"test": true}"#;
211        let (temp_dir, path) = tmp_file(contents).expect("tmp_file failed");
212
213        // File must exist inside the tempdir, not in CWD
214        assert!(path.starts_with(temp_dir.path()));
215        assert_ne!(temp_dir.path(), env::current_dir().unwrap());
216
217        // Contents must match
218        let read_back = fs::read_to_string(&path).expect("read failed");
219        assert_eq!(read_back, contents);
220    }
221
222    #[cfg(unix)]
223    #[test]
224    fn tmp_file_has_restricted_permissions() {
225        use std::os::unix::fs::PermissionsExt;
226
227        let (temp_dir, path) = tmp_file("{}").expect("tmp_file failed");
228
229        let file_meta = fs::metadata(&path).expect("file metadata failed");
230        let file_mode = file_meta.permissions().mode() & 0o777;
231        assert_eq!(file_mode, 0o600, "file permissions should be 0o600");
232
233        let dir_meta = fs::metadata(temp_dir.path()).expect("dir metadata failed");
234        let dir_mode = dir_meta.permissions().mode() & 0o777;
235        assert_eq!(dir_mode, 0o700, "tempdir permissions should be 0o700");
236    }
237}