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