Skip to main content

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, NamedTempFile};
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    read_and_scrub(&mut tmpfile, buf)
77}
78
79/// Read the temporary file's contents into `buf`, then overwrite the file's
80/// bytes with zeros.
81///
82/// The scrub is a best-effort measure before the `NamedTempFile` is unlinked on
83/// drop: in-place overwrite is not guaranteed on CoW/journaling/SSD
84/// filesystems, so the unlink is the real guarantee. The rewind before writing
85/// is essential — `read_to_end` leaves the cursor at EOF, so writing without
86/// seeking back would *append* the zeros after the plaintext (doubling the
87/// file) and leave the secret fully intact.
88///
89/// # Errors
90///
91/// Returns an error if any seek/read/write/truncate/sync operation fails.
92fn read_and_scrub(tmpfile: &mut NamedTempFile, buf: &mut Vec<u8>) -> Result<usize> {
93    // Rewind and read the edited content.
94    tmpfile.seek(SeekFrom::Start(0))?;
95    tmpfile.read_to_end(buf)?;
96
97    // Rewind again before overwriting, then truncate to the original length and
98    // flush so the zeros reach disk.
99    tmpfile.seek(SeekFrom::Start(0))?;
100    let zeros = vec![0u8; buf.len()];
101    tmpfile.write_all(&zeros)?;
102    tmpfile.as_file().set_len(u64::try_from(buf.len())?)?;
103    tmpfile.as_file().sync_all()?;
104
105    Ok(buf.len())
106}
107
108#[cfg(test)]
109#[allow(clippy::unwrap_used)]
110mod tests {
111    use crate::cli::actions::{Action, create, edit, fingerprint, view};
112    use serde_json::Value;
113    use std::io::Write;
114    use tempfile::NamedTempFile;
115
116    struct Test {
117        input: &'static str,
118        public_key: &'static str,
119        private_key: &'static str,
120        header: &'static str,
121    }
122
123    #[test]
124    fn test_create_view_edit_with_input() {
125        let tests = [
126            Test {
127                input: "Machs na",
128                public_key: "test_data/ed25519.pub",
129                private_key: "test_data/ed25519",
130                header: "SSH-VAULT;CHACHA20-POLY1305",
131            },
132            Test {
133                input: "Machs na",
134                public_key: "test_data/id_rsa.pub",
135                private_key: "test_data/id_rsa",
136                header: "SSH-VAULT;AES256",
137            },
138            Test {
139                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",
140                public_key: "test_data/ed25519.pub",
141                private_key: "test_data/ed25519",
142                header: "SSH-VAULT;CHACHA20-POLY1305",
143            },
144        ];
145
146        for test in &tests {
147            let input = test.input;
148            let mut temp_file = NamedTempFile::new().unwrap();
149            temp_file.write_all(input.as_bytes()).unwrap();
150            let vault_file = NamedTempFile::new().unwrap();
151
152            let create = Action::Create {
153                fingerprint: None,
154                key: Some(test.public_key.to_string()),
155                user: None,
156                vault: Some(vault_file.path().to_str().unwrap().to_string()),
157                json: false,
158                input: Some(temp_file.path().to_str().unwrap().to_string()),
159            };
160            let vault = create::handle(create);
161            assert!(vault.is_ok());
162
163            let vault_contents = std::fs::read_to_string(&vault_file).unwrap();
164            assert!(vault_contents.starts_with(test.header));
165
166            let output = NamedTempFile::new().unwrap();
167            let view = Action::View {
168                key: Some(test.private_key.to_string()),
169                output: Some(output.path().to_str().unwrap().to_string()),
170                passphrase: None,
171                vault: Some(vault_file.path().to_str().unwrap().to_string()),
172            };
173            let vault_view = view::handle(view);
174            assert!(vault_view.is_ok());
175
176            let output = std::fs::read_to_string(output).unwrap();
177            assert_eq!(input, output);
178
179            let edit = Action::Edit {
180                key: Some(test.private_key.to_string()),
181                passphrase: None,
182                vault: vault_file.path().to_str().unwrap().to_string(),
183            };
184
185            // set EDITOR to cat instead of vi
186            temp_env::with_vars([("EDITOR", Some("cat"))], || {
187                let vault_edit = edit::handle(edit);
188                assert!(vault_edit.is_ok());
189            });
190
191            let vault_contents_after_edit = std::fs::read_to_string(&vault_file).unwrap();
192            assert_ne!(vault_contents, vault_contents_after_edit);
193
194            // check if we can still view the vault
195            let output = NamedTempFile::new().unwrap();
196            let view = Action::View {
197                key: Some(test.private_key.to_string()),
198                output: Some(output.path().to_str().unwrap().to_string()),
199                passphrase: None,
200                vault: Some(vault_file.path().to_str().unwrap().to_string()),
201            };
202            let vault_view = view::handle(view);
203            assert!(vault_view.is_ok());
204
205            let output = std::fs::read_to_string(output).unwrap();
206            assert_eq!(input, output);
207
208            // try to create again with the same vault (should fail)
209            let create = Action::Create {
210                fingerprint: None,
211                key: Some(test.public_key.to_string()),
212                user: None,
213                vault: Some(vault_file.path().to_str().unwrap().to_string()),
214                json: false,
215                input: Some(temp_file.path().to_str().unwrap().to_string()),
216            };
217            let vault = create::handle(create);
218            assert!(vault.is_err());
219        }
220    }
221
222    #[test]
223    fn test_create_with_json() -> Result<(), Box<dyn std::error::Error>> {
224        let tests = [
225            Test {
226                input: "Three may keep a secret, if two of them are dead",
227                public_key: "test_data/ed25519.pub",
228                private_key: "test_data/ed25519",
229                header: "SSH-VAULT;CHACHA20-POLY1305",
230            },
231            Test {
232                input: "Hello World!",
233                public_key: "test_data/ed25519.pub",
234                private_key: "test_data/ed25519",
235                header: "SSH-VAULT;CHACHA20-POLY1305",
236            },
237        ];
238
239        for test in &tests {
240            let input = test.input;
241            let mut temp_file = NamedTempFile::new().unwrap();
242            temp_file.write_all(input.as_bytes()).unwrap();
243            let vault_json = NamedTempFile::new().unwrap();
244
245            let create = Action::Create {
246                fingerprint: None,
247                key: Some(test.public_key.to_string()),
248                user: None,
249                vault: Some(vault_json.path().to_str().unwrap().to_string()),
250                json: true,
251                input: Some(temp_file.path().to_str().unwrap().to_string()),
252            };
253            let vault = create::handle(create);
254            assert!(vault.is_ok());
255
256            let vault_contents = std::fs::read_to_string(&vault_json).unwrap();
257            let json: Value = serde_json::from_str(&vault_contents).unwrap();
258            let vault_str = json
259                .get("vault")
260                .and_then(|v| v.as_str())
261                .ok_or("Failed to get vault from JSON")?;
262
263            let mut vault_file = NamedTempFile::new().unwrap();
264            vault_file.write_all(vault_str.as_bytes()).unwrap();
265            let output = NamedTempFile::new().unwrap();
266
267            let view = Action::View {
268                key: Some(test.private_key.to_string()),
269                output: Some(output.path().to_str().unwrap().to_string()),
270                passphrase: None,
271                vault: Some(vault_file.path().to_str().unwrap().to_string()),
272            };
273            let vault_view = view::handle(view);
274            assert!(vault_view.is_ok());
275
276            let output = std::fs::read_to_string(output).unwrap();
277            assert_eq!(input, output);
278        }
279        Ok(())
280    }
281
282    // Regression test for the temp-file scrub in `read_and_scrub`.
283    //
284    // The original bug: after `read_to_end` the cursor sits at EOF, so writing
285    // the zero buffer *appended* it (doubling the file) instead of overwriting
286    // the plaintext, leaving the secret fully intact on disk. This asserts the
287    // file is left fully zeroed and at its original length, not doubled.
288    #[test]
289    fn test_read_and_scrub_overwrites_plaintext() {
290        use super::read_and_scrub;
291        use std::io::{Seek, SeekFrom, Write};
292
293        let secret = b"top secret plaintext";
294        let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
295        tmpfile.write_all(secret).unwrap();
296        tmpfile.seek(SeekFrom::Start(0)).unwrap();
297
298        let mut buf = Vec::new();
299        let n = read_and_scrub(&mut tmpfile, &mut buf).unwrap();
300
301        // The edited content is read back correctly.
302        assert_eq!(n, secret.len());
303        assert_eq!(buf.as_slice(), secret);
304
305        // The on-disk file is exactly `secret.len()` bytes (not doubled) and
306        // contains no plaintext — every byte is zero.
307        let on_disk = std::fs::read(tmpfile.path()).unwrap();
308        assert_eq!(on_disk.len(), secret.len());
309        assert!(on_disk.iter().all(|&b| b == 0));
310        assert!(!on_disk.windows(secret.len()).any(|w| w == secret));
311    }
312
313    // Regression test: `view -o <file>` must not leave stale trailing bytes when
314    // the destination file already exists and is longer than the new plaintext.
315    #[test]
316    fn test_view_output_truncates_stale_bytes() {
317        let secret = "short";
318
319        let mut input = NamedTempFile::new().unwrap();
320        input.write_all(secret.as_bytes()).unwrap();
321        let vault_file = NamedTempFile::new().unwrap();
322
323        let create = Action::Create {
324            fingerprint: None,
325            key: Some("test_data/ed25519.pub".to_string()),
326            user: None,
327            vault: Some(vault_file.path().to_str().unwrap().to_string()),
328            json: false,
329            input: Some(input.path().to_str().unwrap().to_string()),
330        };
331        assert!(create::handle(create).is_ok());
332
333        // Pre-populate the output file with content longer than the secret.
334        let mut output_file = NamedTempFile::new().unwrap();
335        output_file
336            .write_all(b"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")
337            .unwrap();
338
339        let view = Action::View {
340            key: Some("test_data/ed25519".to_string()),
341            output: Some(output_file.path().to_str().unwrap().to_string()),
342            passphrase: None,
343            vault: Some(vault_file.path().to_str().unwrap().to_string()),
344        };
345        assert!(view::handle(view).is_ok());
346
347        // The file must contain exactly the secret — no leftover 'X' bytes.
348        let contents = std::fs::read_to_string(output_file.path()).unwrap();
349        assert_eq!(contents, secret);
350    }
351
352    #[test]
353    fn test_fingerprint() {
354        let fingerprint = Action::Fingerprint {
355            key: Some("test_data/ed25519.pub".to_string()),
356            user: None,
357        };
358
359        let fingerprint = fingerprint::handle(fingerprint);
360        assert!(fingerprint.is_ok());
361    }
362}