usereport/
command.rs

1use chrono::Local;
2use log::{debug, trace};
3use serde::{Deserialize, Serialize};
4use std::{
5    io::{Read, Seek, SeekFrom},
6    rc::Rc,
7    time::Duration,
8};
9use subprocess::{Popen, PopenConfig, Redirection};
10use tempfile;
11
12/// Run a CLI command and store its stdout.
13///
14/// # Example
15/// ```
16/// # use usereport::command::{Command, CommandResult};
17/// #[cfg(target_os = "macos")]
18/// let command = Command::new("uname", r#"/usr/bin/uname -a"#)
19///     .with_title("Host OS")
20///     .with_timeout(5);
21/// #[cfg(target_os = "linux")]
22/// let command = Command::new("true", r#"/bin/true"#)
23///     .with_title("Just a successful command")
24///     .with_timeout(5);
25/// match command.exec() {
26///     CommandResult::Success {
27///         command: _,
28///         run_time_ms: _,
29///         stdout: stdout,
30///     } => println!("Command output '{}'", stdout),
31///     CommandResult::Failed {
32///         command: _,
33///         run_time_ms: _,
34///         stdout: stdout,
35///     } => println!("Command failed with output '{}'", stdout),
36///     CommandResult::Timeout {
37///         command: _,
38///         run_time_ms: _,
39///     } => println!("Command timed out"),
40///     CommandResult::Error {
41///         command: _,
42///         reason: reason,
43///     } => println!("Command errored because {}", reason),
44/// };
45/// ```
46#[derive(Debug, Deserialize, PartialEq, Eq, Serialize, Clone)]
47pub struct Command {
48    pub(crate) name:        String,
49    pub(crate) title:       Option<String>,
50    pub(crate) description: Option<String>,
51    pub(crate) command:     String,
52    #[serde(rename = "timeout")]
53    /// Timeout for command execution, defaults to 1 sec if not set
54    pub(crate) timeout_sec: Option<u64>,
55    pub(crate) links:       Option<Vec<Link>>,
56}
57
58impl Command {
59    /// Create new command with default values
60    pub fn new<T: Into<String>>(name: T, command: T) -> Command {
61        Command {
62            name:        name.into(),
63            title:       None,
64            description: None,
65            command:     command.into(),
66            timeout_sec: None,
67            links:       None,
68        }
69    }
70
71    /// Get name of command
72    pub fn name(&self) -> &str { &self.name }
73
74    /// Get command args
75    pub fn command(&self) -> &str { &self.command }
76
77    /// Get title of command
78    pub fn title(&self) -> Option<&str> { self.title.as_ref().map(|x| x.as_str()) }
79
80    /// Get description of command
81    pub fn description(&self) -> Option<&str> { self.description.as_ref().map(|x| x.as_str()) }
82
83    /// Set title of command
84    pub fn with_title<T: Into<String>>(self, title: T) -> Command {
85        Command {
86            title: Some(title.into()),
87            ..self
88        }
89    }
90
91    /// Set title of command
92    pub fn with_timeout<T: Into<Option<u64>>>(self, timeout_sec: T) -> Command {
93        Command {
94            timeout_sec: timeout_sec.into(),
95            ..self
96        }
97    }
98
99    /// Set description of command
100    pub fn with_description<T: Into<String>, S: Into<Option<T>>>(self, description: S) -> Command {
101        Command {
102            description: description.into().map(Into::into),
103            ..self
104        }
105    }
106
107    /// Set Links of command
108    pub fn with_links<T: Into<Option<Vec<Link>>>>(self, links: T) -> Command {
109        Command {
110            links: links.into(),
111            ..self
112        }
113    }
114
115    /// Execute this command; may panic
116    ///
117    /// Standard output and error will be written to a temporary file, because a pipe may only
118    /// contain up 64 KB data; cf. http://man7.org/linux/man-pages/man7/pipe.7.html.
119    pub fn exec(self) -> CommandResult {
120        let args = match self.args() {
121            Ok(args) => args,
122            Err(_) => return self.fail("failed to split command into arguments"),
123        };
124        let mut tmpfile = match tempfile::tempfile() {
125            Ok(f) => Rc::new(f),
126            Err(err) => return self.fail(err),
127        };
128        let popen_config = PopenConfig {
129            stdout: Redirection::RcFile(Rc::clone(&tmpfile)),
130            stderr: Redirection::Merge,
131            ..Default::default()
132        };
133        let start_time = Local::now();
134        let popen = Popen::create(&args, popen_config);
135
136        let mut p = match popen {
137            Ok(p) => p,
138            Err(err) => return self.fail(err),
139        };
140        debug!("Running '{:?}' as '{:?}'", args, p);
141
142        let wait = p.wait_timeout(Duration::new(self.timeout_sec.unwrap_or(1), 0));
143        let run_time_ms = (Local::now() - start_time).num_milliseconds() as u64;
144
145        if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().seek(SeekFrom::Start(0)) {
146            // TODO: unwrap is unsafe
147            return self.fail(err);
148        };
149        match wait {
150            Ok(Some(status)) if status.success() => {
151                debug!(
152                    "{:?} process successfully finished as {:?} with {} bytes output",
153                    args,
154                    status,
155                    tmpfile.metadata().unwrap().len()
156                );
157                let mut stdout = String::new();
158                if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
159                    // TODO: unwrap is unsafe
160                    return self.fail(err);
161                };
162                trace!("stdout '{}'", stdout);
163
164                CommandResult::Success {
165                    command: self,
166                    run_time_ms,
167                    stdout,
168                }
169            }
170            Ok(Some(status)) => {
171                debug!("{:?} process finished as {:?}", args, status);
172                let mut stdout = String::new();
173                if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
174                    // TODO: unwrap is unsafe
175                    return self.fail(err);
176                };
177                trace!("stdout '{}'", stdout);
178                CommandResult::Failed {
179                    command: self,
180                    run_time_ms,
181                    stdout,
182                }
183            }
184            Ok(None) => {
185                debug!("{:?} process timed out and will be killed", args);
186                self.terminate(&mut p);
187                CommandResult::Timeout {
188                    command: self,
189                    run_time_ms,
190                }
191            }
192            Err(err) => {
193                debug!("{:?} process failed '{:?}'", args, err);
194                self.terminate(&mut p);
195                CommandResult::Error {
196                    command: self,
197                    reason:  err.to_string(),
198                }
199            }
200        }
201    }
202
203    /// Fail with error message
204    fn fail<T: ToString>(self, reason: T) -> CommandResult {
205        CommandResult::Error {
206            command: self,
207            reason:  reason.to_string(),
208        }
209    }
210
211    fn args(&self) -> Result<Vec<String>, shellwords::MismatchedQuotes> { shellwords::split(&self.command) }
212
213    /// Panics
214    fn terminate(&self, p: &mut Popen) {
215        p.kill().expect("failed to kill command");
216        p.wait().expect("failed to wait for command to finish");
217        trace!("process killed");
218    }
219}
220
221#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
222pub struct Link {
223    pub(crate) name: String,
224    pub(crate) url:  String,
225}
226
227impl Link {
228    pub fn new<T: Into<String>>(name: T, url: T) -> Link {
229        Link {
230            name: name.into(),
231            url:  url.into(),
232        }
233    }
234
235    pub fn name(&self) -> &str { &self.name }
236
237    pub fn url(&self) -> &str { &self.url }
238}
239
240/// Encapsulates a command execution result
241#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
242pub enum CommandResult {
243    /// `Command` has been executed successfully and `String` contains stdout.
244    Success {
245        command:     Command,
246        run_time_ms: u64,
247        stdout:      String,
248    },
249    /// `Command` failed to execute
250    Failed {
251        command:     Command,
252        run_time_ms: u64,
253        stdout:      String,
254    },
255    /// `Command` execution exceeded specified timeout
256    Timeout { command: Command, run_time_ms: u64 },
257    /// `Command` could not be executed
258    Error { command: Command, reason: String },
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::tests::*;
265
266    use spectral::prelude::*;
267
268    #[test]
269    fn execution_ok() {
270        init();
271
272        #[cfg(target_os = "macos")]
273        let command = Command::new("true", r#"/usr/bin/true"#);
274        #[cfg(target_os = "linux")]
275        let command = Command::new("true", r#"/bin/true"#);
276
277        let res = command.exec();
278
279        asserting("executing command successfully")
280            .that(&res)
281            .is_success_contains("");
282    }
283
284    #[test]
285    fn execution_failed() {
286        init();
287
288        #[cfg(target_os = "macos")]
289        let command = Command::new("false", r#"/usr/bin/false"#);
290        #[cfg(target_os = "linux")]
291        let command = Command::new("false", r#"/bin/false"#);
292
293        let res = command.exec();
294
295        asserting("executing command successfully").that(&res).is_failed();
296    }
297
298    #[test]
299    fn execution_timeout() {
300        init();
301
302        let command = Command::new("sleep", r#"/bin/sleep 5"#).with_timeout(1);
303
304        let res = command.exec();
305
306        asserting("executing command successfully").that(&res).is_timeout();
307    }
308
309    #[test]
310    fn execution_error() {
311        init();
312
313        let command = Command::new("no_such_command", r#"/no_such_command"#);
314
315        let res = command.exec();
316
317        asserting("executing command errors")
318            .that(&res)
319            .is_error_contains("No such file or directory")
320    }
321
322    #[test]
323    fn command_split() {
324        init();
325
326        let command = Command::new("no_such_command", r#"/bin/sleep 5"#);
327        let args = command.args();
328
329        asserting("splitting command into args")
330            .that(&args)
331            .is_ok()
332            .has_length(2);
333    }
334
335    #[test]
336    fn command_split_single_quotes() {
337        init();
338
339        let command = Command::new("no_such_command", r#"sh -c 'dmesg -T | grep "failed"'"#);
340        let args = command.args();
341
342        asserting("splitting command into args")
343            .that(&args)
344            .is_ok()
345            .has_length(3);
346    }
347
348    #[test]
349    fn command_split_double_quotes() {
350        init();
351
352        let command = Command::new("no_such_command", r#"sh -c "dmesg -T | tail""#);
353        let args = command.args();
354
355        asserting("splitting command into args")
356            .that(&args)
357            .is_ok()
358            .has_length(3);
359    }
360}