From af4b0ad93cf16314b48e769d7297c6d75f002835 Mon Sep 17 00:00:00 2001
From: Daniel Mueller <deso@posteo.net>
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<Selection, Self::Err> {
+    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<time::Duration, anyhow::Error> {
+  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<Vec<u8>> {
+  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<time::Duration>,
+  /// 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.3