Skip to main content

ssh_vault/vault/
dio.rs

1use std::fs::{File, OpenOptions};
2use std::io::{self, IsTerminal, Read, Write};
3
4pub enum InputSource {
5    Stdin,
6    File(File),
7}
8
9impl InputSource {
10    /// Create a new input source from an optional file path.
11    ///
12    /// # Errors
13    ///
14    /// Returns an error if the provided file cannot be opened.
15    pub fn new(input: Option<String>) -> io::Result<Self> {
16        if let Some(filename) = input {
17            // Use a file if the filename is not "-" (stdin)
18            if filename != "-" {
19                return Ok(Self::File(File::open(filename)?));
20            }
21        }
22
23        Ok(Self::Stdin)
24    }
25
26    #[must_use]
27    pub fn is_terminal(&self) -> bool {
28        matches!(self, Self::Stdin) && io::stdin().is_terminal()
29    }
30}
31
32impl Read for InputSource {
33    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
34        match self {
35            Self::Stdin => io::stdin().read(buf),
36            Self::File(file) => file.read(buf),
37        }
38    }
39}
40
41// OutputDestination is a wrapper around stdout or a temporary file
42pub enum OutputDestination {
43    Stdout,
44    File(File),
45}
46
47impl OutputDestination {
48    /// Create a new output destination from an optional file path.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the provided file cannot be created or opened.
53    #[allow(clippy::suspicious_open_options)]
54    pub fn new(output: Option<String>) -> io::Result<Self> {
55        if let Some(filename) = output {
56            // Use a file if the filename is not "-" (stdout)
57            if filename != "-" {
58                let mut options = OpenOptions::new();
59                options.write(true).create(true);
60                // Restrict newly-created output files to the owner (0600). This
61                // matters for `view -o`, whose output is decrypted plaintext;
62                // the default umask would otherwise create it world/group
63                // readable. mode() only affects files this call creates.
64                #[cfg(unix)]
65                {
66                    use std::os::unix::fs::OpenOptionsExt;
67                    options.mode(0o600);
68                }
69                return Ok(Self::File(options.open(filename)?));
70            }
71        }
72
73        Ok(Self::Stdout)
74    }
75
76    /// Truncate the underlying file (if any).
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if truncation fails.
81    pub fn truncate(&self) -> io::Result<()> {
82        match self {
83            Self::File(file) => file.set_len(0),
84            Self::Stdout => Ok(()), // Do nothing for stdout
85        }
86    }
87
88    // Check if the output is empty, preventing overwriting a non-empty file
89    /// Check whether the output destination is empty.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if file metadata cannot be read.
94    pub fn is_empty(&self) -> io::Result<bool> {
95        match self {
96            Self::File(file) => Ok(file.metadata().is_ok_and(|metadata| metadata.len() == 0)),
97            Self::Stdout => Ok(true), // Do nothing for stdout
98        }
99    }
100}
101
102impl Write for OutputDestination {
103    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
104        match self {
105            Self::Stdout => io::stdout().write(buf),
106            Self::File(file) => file.write(buf),
107        }
108    }
109
110    fn flush(&mut self) -> io::Result<()> {
111        match self {
112            Self::Stdout => io::stdout().flush(),
113            Self::File(file) => file.flush(),
114        }
115    }
116}
117
118/// Configure input and output sources for CLI commands.
119///
120/// # Errors
121///
122/// Returns an error if either the input or output file cannot be opened.
123pub fn setup_io(
124    input: Option<String>,
125    output: Option<String>,
126) -> io::Result<(InputSource, OutputDestination)> {
127    let input = InputSource::new(input)?;
128    let output = OutputDestination::new(output)?;
129
130    Ok((input, output))
131}
132
133#[cfg(test)]
134#[allow(clippy::unwrap_used)]
135mod tests {
136    use super::*;
137    use tempfile::NamedTempFile;
138
139    #[test]
140    fn test_setup_io() {
141        let stdin_is_terminal = io::stdin().is_terminal();
142
143        let (input, output) = setup_io(None, None).unwrap();
144        assert!(matches!(input, InputSource::Stdin));
145        assert_eq!(input.is_terminal(), stdin_is_terminal);
146        assert!(matches!(output, OutputDestination::Stdout));
147
148        let (input, output) = setup_io(Some("-".to_string()), None).unwrap();
149        assert!(matches!(input, InputSource::Stdin));
150        assert_eq!(input.is_terminal(), stdin_is_terminal);
151        assert!(matches!(output, OutputDestination::Stdout));
152
153        let rs = setup_io(Some("noneexistent".to_string()), None);
154        assert!(rs.is_err());
155    }
156
157    #[test]
158    fn test_setup_io_file() {
159        let output_file = NamedTempFile::new().unwrap();
160
161        let (input, output) = setup_io(Some("Cargo.toml".to_string()), None).unwrap();
162        assert!(!input.is_terminal());
163        assert!(matches!(output, OutputDestination::Stdout));
164
165        let (input, output) =
166            setup_io(Some("Cargo.toml".to_string()), Some("-".to_string())).unwrap();
167        assert!(!input.is_terminal());
168        assert!(matches!(output, OutputDestination::Stdout));
169
170        let (input, output) = setup_io(
171            Some("Cargo.toml".to_string()),
172            Some(output_file.path().to_str().unwrap().to_string()),
173        )
174        .unwrap();
175        assert!(!input.is_terminal());
176        assert!(matches!(output, OutputDestination::File(_)));
177
178        // File is directory
179        let rs = setup_io(Some("Cargo.toml".to_string()), Some("/".to_string()));
180        assert!(rs.is_err());
181    }
182
183    #[test]
184    fn test_input_source() {
185        let mut input = InputSource::new(Some("Cargo.toml".to_string())).unwrap();
186        let mut buf = [0; 1024];
187        let n = input.read(&mut buf).unwrap();
188        assert!(n > 0);
189
190        let rs = InputSource::new(Some("noneexistent".to_string()));
191        assert!(rs.is_err());
192    }
193
194    #[test]
195    fn test_output_destination() {
196        let mut output = OutputDestination::new(Some("-".to_string())).unwrap();
197        let n = output.write(b"test").unwrap();
198        assert_eq!(n, 4);
199
200        let mut output = OutputDestination::new(None).unwrap();
201        let n = output.write(b"test").unwrap();
202        assert_eq!(n, 4);
203
204        let output_file = NamedTempFile::new().unwrap();
205        let mut output =
206            OutputDestination::new(Some(output_file.path().to_str().unwrap().to_string())).unwrap();
207        let n = output.write(b"test").unwrap();
208        assert_eq!(n, 4);
209    }
210
211    #[test]
212    fn test_output_destination_truncate() {
213        let mut output_file = NamedTempFile::new().unwrap();
214        let mut output =
215            OutputDestination::new(Some(output_file.path().to_str().unwrap().to_string())).unwrap();
216        let n = output.write(b"test").unwrap();
217        assert_eq!(n, 4);
218
219        output.truncate().unwrap();
220        let mut buf = [0; 1024];
221        let n = output_file.read(&mut buf).unwrap();
222        assert_eq!(n, 0);
223    }
224
225    #[cfg(unix)]
226    #[test]
227    fn test_output_destination_new_file_mode() {
228        use std::os::unix::fs::PermissionsExt;
229
230        let dir = tempfile::tempdir().unwrap();
231        let path = dir.path().join("secret.txt");
232
233        let _output = OutputDestination::new(Some(path.to_str().unwrap().to_string())).unwrap();
234
235        // Newly-created output files must be owner-only (0600).
236        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
237        assert_eq!(mode, 0o600);
238    }
239
240    #[test]
241    fn test_output_destination_is_empty() {
242        let output_file = NamedTempFile::new().unwrap();
243        let mut output =
244            OutputDestination::new(Some(output_file.path().to_str().unwrap().to_string())).unwrap();
245        let n = output.write(b"test").unwrap();
246        assert_eq!(n, 4);
247
248        let is_empty = output.is_empty().unwrap();
249        assert!(!is_empty);
250
251        output.truncate().unwrap();
252        let is_empty = output.is_empty().unwrap();
253        assert!(is_empty);
254    }
255}