From af4b0ad93cf16314b48e769d7297c6d75f002835 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Tue, 6 Oct 2020 20:41:08 -0700 Subject: Add simple clipboard management --- Cargo.toml | 4 + var/clipboard.rs | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 var/clipboard.rs diff --git a/Cargo.toml b/Cargo.toml index 167474b..d934aaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ exclude = ["ci/*", "rustfmt.toml"] [badges] gitlab = { repository = "d-e-s-o/nitrocli", branch = "master" } +[[bin]] +name = "clipboard" +path = "var/clipboard.rs" + [[bin]] name = "shell-complete" path = "var/shell-complete.rs" diff --git a/var/clipboard.rs b/var/clipboard.rs new file mode 100644 index 0000000..9d7ba21 --- /dev/null +++ b/var/clipboard.rs @@ -0,0 +1,225 @@ +// clipboard.rs + +// Copyright (C) 2020 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::ffi; +use std::fmt; +use std::io::Write as _; +use std::os::unix::ffi::OsStrExt as _; +use std::process; +use std::str; +use std::thread; +use std::time; + +use anyhow::Context as _; +use structopt::StructOpt as _; + +#[derive(Clone, Copy, Debug, PartialEq, structopt::StructOpt)] +enum Selection { + Primary, + Secondary, + Clipboard, +} + +impl fmt::Display for Selection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Primary => "primary", + Self::Secondary => "secondary", + Self::Clipboard => "clipboard", + }; + fmt::Display::fmt(s, f) + } +} + +impl str::FromStr for Selection { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "primary" => Ok(Self::Primary), + "secondary" => Ok(Self::Secondary), + "clipboard" => Ok(Self::Clipboard), + _ => Err(anyhow::anyhow!("Unexpected selection type: {}", s)), + } + } +} + +/// Parse a duration from a string. +fn parse_duration(s: &str) -> Result { + let durations = [ + ("ms", 1), + ("sec", 1000), + ("s", 1000), + ("min", 60000), + ("m", 60000), + ]; + + for (suffix, multiplier) in &durations { + if s.ends_with(suffix) { + if let Ok(count) = u64::from_str_radix(&s[..s.len() - suffix.len()], 10) { + return Ok(time::Duration::from_millis(count * multiplier)); + } + } + } + + anyhow::bail!("invalid duration provided: {}", s) +} + +fn copy(selection: Selection, content: &[u8]) -> anyhow::Result<()> { + let mut clip = process::Command::new("xclip") + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .args(&["-selection", &selection.to_string()]) + .spawn() + .context("Failed to execute xclip")?; + + let stdin = clip.stdin.as_mut().unwrap(); + stdin + .write_all(content) + .context("Failed to write to stdin")?; + + let output = clip.wait().context("Failed to wait for xclip for finish")?; + anyhow::ensure!(output.success(), "xclip failed"); + Ok(()) +} + +/// Retrieve the current clipboard contents. +fn clipboard(selection: Selection) -> anyhow::Result> { + let output = process::Command::new("xclip") + .args(&["-out", "-selection", &selection.to_string()]) + .output() + .context("Failed to execute xclip")?; + + anyhow::ensure!( + output.status.success(), + "xclip failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(output.stdout) +} + +/// Access Nitrokey OTP slots by name +#[derive(Debug, structopt::StructOpt)] +#[structopt()] +struct Args { + /// The "selection" to use (see xclip(1)). + #[structopt(short, long, default_value = "clipboard")] + selection: Selection, + /// Revert the contents of the clipboard to the previous value after + /// this time. + #[structopt(short, long, parse(try_from_str = parse_duration))] + revert_after: Option, + /// The data to copy to the clipboard. + #[structopt(name = "data")] + data: ffi::OsString, +} + +/// Revert clipboard contents after a while. +fn revert_contents( + delay: time::Duration, + selection: Selection, + expected: &[u8], + previous: &[u8], +) -> anyhow::Result<()> { + let pid = unsafe { libc::fork() }; + if pid == 0 { + // We are in the child. Sleep for the provided delay and then revert + // the clipboard contents. + thread::sleep(delay); + // We potentially suffer from A-B-A as well as TOCTOU problems here. + // But who's checking... + let content = clipboard(selection).context("Failed to save clipboard contents")?; + if content == expected { + copy(selection, previous).context("Failed to restore original xclip content")?; + } + Ok(()) + } else if pid < 0 { + // TODO: Could provide errno or whatever describes the failure. + anyhow::bail!("Failed to fork") + } else { + debug_assert!(pid > 0); + // We are in the parent. There is nothing to do but to exit. + Ok(()) + } +} + +fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + + let revert = if let Some(revert_after) = args.revert_after { + let content = match clipboard(args.selection) { + Ok(content) => content, + // If the clipboard/selection is "empty" xclip reports this + // nonsense and fails. We have no other way to detect it than + // pattern matching on its output, but we definitely want to + // handle this case gracefully. + Err(err) if err.to_string().contains("target STRING not available") => Vec::new(), + e => e.context("Failed to save clipboard contents")?, + }; + Some((revert_after, content)) + } else { + None + }; + + copy(args.selection, args.data.as_bytes()).context("Failed to modify clipboard contents")?; + + if let Some((revert_after, previous)) = revert { + revert_contents( + revert_after, + args.selection, + args.data.as_bytes(), + &previous, + ) + .context("Failed to revert clipboard contents")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duration_parsing() { + assert_eq!( + parse_duration("1ms").unwrap(), + time::Duration::from_millis(1) + ); + assert_eq!( + parse_duration("500ms").unwrap(), + time::Duration::from_millis(500) + ); + assert_eq!(parse_duration("1s").unwrap(), time::Duration::from_secs(1)); + assert_eq!( + parse_duration("1sec").unwrap(), + time::Duration::from_secs(1) + ); + assert_eq!( + parse_duration("13s").unwrap(), + time::Duration::from_secs(13) + ); + assert_eq!( + parse_duration("13sec").unwrap(), + time::Duration::from_secs(13) + ); + assert_eq!( + parse_duration("1m").unwrap(), + time::Duration::from_secs(1 * 60) + ); + assert_eq!( + parse_duration("1min").unwrap(), + time::Duration::from_secs(1 * 60) + ); + assert_eq!( + parse_duration("13m").unwrap(), + time::Duration::from_secs(13 * 60) + ); + assert_eq!( + parse_duration("13min").unwrap(), + time::Duration::from_secs(13 * 60) + ); + } +} -- cgit v1.2.1