ssh_vault/cli/actions/
mod.rs

1pub mod create;
2pub mod edit;
3pub mod fingerprint;
4pub mod view;
5
6use crate::tools;
7use anyhow::{Result, anyhow};
8use secrecy::{ExposeSecret, SecretString};
9use std::{
10    env,
11    io::{Read, Seek, SeekFrom, Write},
12    process::Command,
13};
14use tempfile::Builder;
15
16#[derive(Debug)]
17pub enum Action {
18    Fingerprint {
19        key: Option<String>,
20        user: Option<String>,
21    },
22    Create {
23        fingerprint: Option<String>,
24        input: Option<String>,
25        json: bool,
26        key: Option<String>,
27        user: Option<String>,
28        vault: Option<String>,
29    },
30    View {
31        key: Option<String>,
32        output: Option<String>,
33        passphrase: Option<SecretString>,
34        vault: Option<String>,
35    },
36    Edit {
37        key: Option<String>,
38        passphrase: Option<SecretString>,
39        vault: String,
40    },
41    Help,
42}
43
44/// Opens an editor and returns the edited content.
45///
46/// # Errors
47///
48/// Returns an error if the temporary file cannot be created, if the editor
49/// command is empty or fails, or if reading/writing the temporary file fails.
50pub fn process_input(buf: &mut Vec<u8>, data: Option<SecretString>) -> Result<usize> {
51    let mut tmpfile = Builder::new()
52        .prefix(".vault-")
53        .suffix(".ssh")
54        .tempfile_in(tools::get_home()?)?;
55
56    if let Some(data) = data {
57        write!(tmpfile, "{}", data.expose_secret())?;
58    }
59
60    let editor = env::var("EDITOR").unwrap_or_else(|_| String::from("vi"));
61
62    let editor_parts = shell_words::split(&editor)?;
63    let command = editor_parts
64        .first()
65        .ok_or_else(|| anyhow!("EDITOR command is empty"))?;
66
67    let status = Command::new(command)
68        .args(editor_parts.get(1..).unwrap_or(&[]))
69        .arg(tmpfile.path())
70        .status()?;
71
72    if !status.success() {
73        return Err(anyhow!("Editor exited with non-zero status code"));
74    }
75
76    // Seek to start
77    tmpfile.seek(SeekFrom::Start(0))?;
78
79    // read the file
80    tmpfile.read_to_end(buf)?;
81
82    // Fill the file with zeros
83    let zeros = vec![0u8; buf.len()];
84    tmpfile.write_all(&zeros)?;
85
86    Ok(buf.len())
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used)]
91mod tests {
92    use crate::cli::actions::{Action, create, edit, fingerprint, view};
93    use serde_json::Value;
94    use std::io::Write;
95    use tempfile::NamedTempFile;
96
97    struct Test {
98        input: &'static str,
99        public_key: &'static str,
100        private_key: &'static str,
101        header: &'static str,
102    }
103
104    #[test]
105    fn test_create_view_edit_with_input() {
106        let tests = [
107            Test {
108                input: "Machs na",
109                public_key: "test_data/ed25519.pub",
110                private_key: "test_data/ed25519",
111                header: "SSH-VAULT;CHACHA20-POLY1305",
112            },
113            Test {
114                input: "Machs na",
115                public_key: "test_data/id_rsa.pub",
116                private_key: "test_data/id_rsa",
117                header: "SSH-VAULT;AES256",
118            },
119            Test {
120                input: "Arrachera is a Mexican dish made from marinated and grilled skirt steak. The steak is seasoned with a mixture of spices and marinades, giving it a rich and savory flavor. Commonly served in tacos or fajitas, arrachera is known for its tenderness and versatility in Mexican cuisine",
121                public_key: "test_data/ed25519.pub",
122                private_key: "test_data/ed25519",
123                header: "SSH-VAULT;CHACHA20-POLY1305",
124            },
125        ];
126
127        for test in &tests {
128            let input = test.input;
129            let mut temp_file = NamedTempFile::new().unwrap();
130            temp_file.write_all(input.as_bytes()).unwrap();
131            let vault_file = NamedTempFile::new().unwrap();
132
133            let create = Action::Create {
134                fingerprint: None,
135                key: Some(test.public_key.to_string()),
136                user: None,
137                vault: Some(vault_file.path().to_str().unwrap().to_string()),
138                json: false,
139                input: Some(temp_file.path().to_str().unwrap().to_string()),
140            };
141            let vault = create::handle(create);
142            assert!(vault.is_ok());
143
144            let vault_contents = std::fs::read_to_string(&vault_file).unwrap();
145            assert!(vault_contents.starts_with(test.header));
146
147            let output = NamedTempFile::new().unwrap();
148            let view = Action::View {
149                key: Some(test.private_key.to_string()),
150                output: Some(output.path().to_str().unwrap().to_string()),
151                passphrase: None,
152                vault: Some(vault_file.path().to_str().unwrap().to_string()),
153            };
154            let vault_view = view::handle(view);
155            assert!(vault_view.is_ok());
156
157            let output = std::fs::read_to_string(output).unwrap();
158            assert_eq!(input, output);
159
160            let edit = Action::Edit {
161                key: Some(test.private_key.to_string()),
162                passphrase: None,
163                vault: vault_file.path().to_str().unwrap().to_string(),
164            };
165
166            // set EDITOR to cat instead of vi
167            temp_env::with_vars([("EDITOR", Some("cat"))], || {
168                let vault_edit = edit::handle(edit);
169                assert!(vault_edit.is_ok());
170            });
171
172            let vault_contents_after_edit = std::fs::read_to_string(&vault_file).unwrap();
173            assert_ne!(vault_contents, vault_contents_after_edit);
174
175            // check if we can still view the vault
176            let output = NamedTempFile::new().unwrap();
177            let view = Action::View {
178                key: Some(test.private_key.to_string()),
179                output: Some(output.path().to_str().unwrap().to_string()),
180                passphrase: None,
181                vault: Some(vault_file.path().to_str().unwrap().to_string()),
182            };
183            let vault_view = view::handle(view);
184            assert!(vault_view.is_ok());
185
186            let output = std::fs::read_to_string(output).unwrap();
187            assert_eq!(input, output);
188
189            // try to create again with the same vault (should fail)
190            let create = Action::Create {
191                fingerprint: None,
192                key: Some(test.public_key.to_string()),
193                user: None,
194                vault: Some(vault_file.path().to_str().unwrap().to_string()),
195                json: false,
196                input: Some(temp_file.path().to_str().unwrap().to_string()),
197            };
198            let vault = create::handle(create);
199            assert!(vault.is_err());
200        }
201    }
202
203    #[test]
204    fn test_create_with_json() -> Result<(), Box<dyn std::error::Error>> {
205        let tests = [
206            Test {
207                input: "Three may keep a secret, if two of them are dead",
208                public_key: "test_data/ed25519.pub",
209                private_key: "test_data/ed25519",
210                header: "SSH-VAULT;CHACHA20-POLY1305",
211            },
212            Test {
213                input: "Hello World!",
214                public_key: "test_data/ed25519.pub",
215                private_key: "test_data/ed25519",
216                header: "SSH-VAULT;CHACHA20-POLY1305",
217            },
218        ];
219
220        for test in &tests {
221            let input = test.input;
222            let mut temp_file = NamedTempFile::new().unwrap();
223            temp_file.write_all(input.as_bytes()).unwrap();
224            let vault_json = NamedTempFile::new().unwrap();
225
226            let create = Action::Create {
227                fingerprint: None,
228                key: Some(test.public_key.to_string()),
229                user: None,
230                vault: Some(vault_json.path().to_str().unwrap().to_string()),
231                json: true,
232                input: Some(temp_file.path().to_str().unwrap().to_string()),
233            };
234            let vault = create::handle(create);
235            assert!(vault.is_ok());
236
237            let vault_contents = std::fs::read_to_string(&vault_json).unwrap();
238            let json: Value = serde_json::from_str(&vault_contents).unwrap();
239            let vault_str = json
240                .get("vault")
241                .and_then(|v| v.as_str())
242                .ok_or("Failed to get vault from JSON")?;
243
244            let mut vault_file = NamedTempFile::new().unwrap();
245            vault_file.write_all(vault_str.as_bytes()).unwrap();
246            let output = NamedTempFile::new().unwrap();
247
248            let view = Action::View {
249                key: Some(test.private_key.to_string()),
250                output: Some(output.path().to_str().unwrap().to_string()),
251                passphrase: None,
252                vault: Some(vault_file.path().to_str().unwrap().to_string()),
253            };
254            let vault_view = view::handle(view);
255            assert!(vault_view.is_ok());
256
257            let output = std::fs::read_to_string(output).unwrap();
258            assert_eq!(input, output);
259        }
260        Ok(())
261    }
262
263    #[test]
264    fn test_fingerprint() {
265        let fingerprint = Action::Fingerprint {
266            key: Some("test_data/ed25519.pub".to_string()),
267            user: None,
268        };
269
270        let fingerprint = fingerprint::handle(fingerprint);
271        assert!(fingerprint.is_ok());
272    }
273}