diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c36ee2..1a8f1e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: - name: gitleaks uses: gitleaks/gitleaks-action@v2 env: + # The action downloads a fixed filename; keep it in job-local temp. + TMPDIR: ${{ runner.temp }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_CONFIG: .gitleaks.toml diff --git a/bws-tui/Cargo.lock b/bws-tui/Cargo.lock index bf92da9..a66501b 100644 --- a/bws-tui/Cargo.lock +++ b/bws-tui/Cargo.lock @@ -96,6 +96,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" @@ -108,6 +114,7 @@ version = "0.0.4" dependencies = [ "anyhow", "arboard", + "base64", "clap", "crossterm", "humantime", diff --git a/bws-tui/Cargo.toml b/bws-tui/Cargo.toml index 43ceb27..b0b3749 100644 --- a/bws-tui/Cargo.toml +++ b/bws-tui/Cargo.toml @@ -24,3 +24,4 @@ anyhow = "1" humantime = "2" clap = { version = "4", features = ["derive"] } arboard = "3" +base64 = "0.22" diff --git a/bws-tui/README.md b/bws-tui/README.md index c80c088..6c2fbe7 100644 --- a/bws-tui/README.md +++ b/bws-tui/README.md @@ -53,7 +53,7 @@ The official `bws` CLI already has the authentication and API access. What it do - **Type to find.** Fuzzy filtering is always live; arrows always move. - **Enter means actions, never exit.** Copy, Reveal, Edit, Delete, or Cancel appear as plain English. - **Values stay out of lists and logs.** Reveal is deliberate; copy is explicit. -- **Clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open. +- **Native clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open. Headless terminal copies use OSC 52 and must be cleared manually. - **Projects never drift.** The menu comes from `bws project list` every time. - **No config file.** `bws` keeps its token/profile configuration; `hush` adds none. @@ -101,7 +101,7 @@ cargo install --locked bws-tui hush ``` -Tested on macOS. The crate uses `arboard` for cross-platform clipboard access, but other platforms are not yet part of the release verification matrix. +On macOS and Linux desktops, `hush` uses the native clipboard through `arboard`. On headless Linux, it sends an OSC 52 clipboard write through the terminal. This fallback requires stdout to be a terminal; redirected output is rejected before writing value bytes. Native clipboard copies can still work with redirected stdout. OSC 52 cannot confirm delivery or read back the clipboard, so `hush` does not automatically clear terminal copies. Clear them manually. Terminal copies are limited to 128 KiB of value text. Under tmux, enable `set-clipboard on` and ensure the outer terminal supports clipboard writes. ## The interface @@ -198,7 +198,7 @@ That narrow boundary is the point. `hush` is an interface, not another secrets p - **Masked entry:** TUI value input is never rendered as plaintext while typing. - **No values in lists:** search rows contain only key and project name. - **Sanitized failures:** failed `bws` operations report the operation and exit status, never the secret-bearing argv. -- **Conditional clipboard clear:** after 30 seconds, `hush` clears the clipboard only if it still contains the value it copied. Your newer clipboard content is left alone. +- **Conditional native clipboard clear:** after 30 seconds, `hush` clears a native clipboard only if it still contains the value it copied. Your newer clipboard content is left alone. OSC 52 copies are not auto-cleared because the terminal cannot report whether the clipboard changed. - **Delete guard:** the TUI confirms deletion; scripts require `--yes`. - **Scoped reads:** `get` and `exec` fetch single secrets by id; only `list`/`sync` refreshes see the full metadata set, and nothing persists values. - **Names-only audit log:** every operation is recorded with timestamp, verb, and key names — never values. diff --git a/bws-tui/src/tui.rs b/bws-tui/src/tui.rs index 9361472..f999423 100644 --- a/bws-tui/src/tui.rs +++ b/bws-tui/src/tui.rs @@ -1,5 +1,6 @@ mod actions; mod chrome; +mod clipboard; mod events; mod forms; mod render; @@ -7,9 +8,9 @@ mod secrets; mod terminal; use crate::bws::{self, Project, Secret}; -use anyhow::{Context, Result}; -use arboard::Clipboard; -use secrecy::{ExposeSecret, SecretString}; +use anyhow::Result; +use clipboard::*; +use secrecy::SecretString; use std::time::{Duration, Instant}; const ACCENT: ratatui::style::Color = ratatui::style::Color::Cyan; @@ -208,34 +209,6 @@ fn fuzzy_match(hay: &str, needle: &str) -> bool { cur.is_none() } -fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool { - current == copied.expose_secret() -} - -fn clear_clipboard_value(copied: &SecretString) -> Result { - let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?; - let current = clipboard - .get_text() - .context("failed to read the clipboard")?; - if !clipboard_holds_copied_value(¤t, copied) { - return Ok(false); - } - clipboard - .set_text(String::new()) - .context("failed to clear the clipboard")?; - Ok(true) -} - -fn copy_with_autoclear(app: &mut App, value: String) -> Result<()> { - let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?; - clipboard - .set_text(value.clone()) - .context("failed to copy the secret value")?; - app.clipboard_value = Some(SecretString::from(value)); - app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30)); - Ok(()) -} - pub fn run() -> Result<()> { let mut app = App::new(bws::list_projects()?); terminal::run(&mut app) diff --git a/bws-tui/src/tui/actions.rs b/bws-tui/src/tui/actions.rs index 79378a0..f97810b 100644 --- a/bws-tui/src/tui/actions.rs +++ b/bws-tui/src/tui/actions.rs @@ -1,11 +1,24 @@ use super::*; +use std::io::{IsTerminal, Write}; -pub(super) fn copy_action(app: &mut App) { +/// Copies the selected value and reports the delivery method without revealing it. +pub(super) fn copy_action(app: &mut App, output: &mut impl Write) { if let Some(s) = app.selected_secret() { let key = s.key.clone(); let value = s.value.clone(); - match copy_with_autoclear(app, value) { - Ok(()) => app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)")), + match copy_value( + app, + value, + output, + should_try_native_clipboard(), + std::io::stdout().is_terminal(), + ) { + Ok(CopyMethod::Native) => { + app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)")) + } + Ok(CopyMethod::Terminal) => app.set_ok(format!( + "sent “{key}” to terminal clipboard (OSC 52); clear manually" + )), Err(error) => app.set_err(&error), } } @@ -22,9 +35,10 @@ pub(super) fn edit_action(app: &mut App) { } } -pub(super) fn run_action(app: &mut App) { +/// Dispatches the selected secret action using the TUI's terminal output. +pub(super) fn run_action(app: &mut App, output: &mut impl Write) { match app.action_idx { - 0 => copy_action(app), + 0 => copy_action(app, output), 1 => app.revealed = true, 2 => edit_action(app), 3 => app.mode = Mode::ConfirmDelete, diff --git a/bws-tui/src/tui/clipboard.rs b/bws-tui/src/tui/clipboard.rs new file mode 100644 index 0000000..f282cbd --- /dev/null +++ b/bws-tui/src/tui/clipboard.rs @@ -0,0 +1,108 @@ +use super::App; +use anyhow::{Context, Result}; +use arboard::Clipboard; +use base64::{engine::general_purpose::STANDARD, Engine}; +use secrecy::{ExposeSecret, SecretString}; +use std::{ + ffi::OsStr, + io::Write, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +/// Reports whether the clipboard still holds the value hush copied. +pub(super) fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool { + current == copied.expose_secret() +} + +/// Clears a native copy only when it has not been replaced. +pub(super) fn clear_clipboard_value(copied: &SecretString) -> Result { + let mut clipboard = Clipboard::new().context("native clipboard is unavailable")?; + let current = clipboard + .get_text() + .context("failed to read the clipboard")?; + if !clipboard_holds_copied_value(¤t, copied) { + return Ok(false); + } + clipboard + .set_text(String::new()) + .context("failed to clear the clipboard")?; + Ok(true) +} + +#[derive(Debug, PartialEq)] +pub(super) enum CopyMethod { + Native, + Terminal, +} + +const MAX_OSC52_VALUE_BYTES: usize = 128 * 1024; + +/// Detects a configured X11 or Wayland display. +pub(super) fn has_display_server(display: Option<&OsStr>, wayland: Option<&OsStr>) -> bool { + display.is_some_and(|value| !value.is_empty()) || wayland.is_some_and(|value| !value.is_empty()) +} + +/// Skips arboard on headless Linux while keeping the native path elsewhere. +pub(super) fn should_try_native_clipboard() -> bool { + !cfg!(target_os = "linux") + || has_display_server( + std::env::var_os("DISPLAY").as_deref(), + std::env::var_os("WAYLAND_DISPLAY").as_deref(), + ) +} + +/// Sends a bounded OSC 52 clipboard write through the active terminal output. +pub(super) fn write_osc52(value: &str, output: &mut impl Write) -> Result<()> { + anyhow::ensure!( + value.len() <= MAX_OSC52_VALUE_BYTES, + "terminal clipboard OSC 52 value exceeds 128 KiB" + ); + let encoded = Zeroizing::new(STANDARD.encode(value)); + output.write_all(b"\x1b]52;c;")?; + output.write_all(encoded.as_bytes())?; + output.write_all(b"\x07")?; + output + .flush() + .context("failed to flush terminal clipboard")?; + Ok(()) +} + +/// Uses the native clipboard first and falls back to OSC 52 when unavailable. +/// OSC 52 requires terminal output; only native copies schedule a conditional clear. +pub(super) fn copy_value( + app: &mut App, + value: String, + output: &mut impl Write, + try_native: bool, + terminal_output: bool, +) -> Result { + let native_error = if try_native { + match Clipboard::new() + .context("native clipboard is unavailable") + .and_then(|mut clipboard| { + clipboard + .set_text(value.clone()) + .context("failed to copy using native clipboard") + }) { + Ok(()) => { + app.clipboard_value = Some(SecretString::from(value)); + app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30)); + return Ok(CopyMethod::Native); + } + Err(error) => error, + } + } else { + anyhow::anyhow!("native clipboard unavailable: no display server") + }; + + anyhow::ensure!( + terminal_output, + "{native_error:#}; terminal clipboard OSC 52 unavailable: stdout is not a terminal" + ); + write_osc52(&value, output).map_err(|error| { + anyhow::anyhow!("{native_error:#}; terminal clipboard OSC 52 failed: {error:#}") + })?; + // OSC 52 cannot read back the clipboard. Keep any earlier native clear pending. + Ok(CopyMethod::Terminal) +} diff --git a/bws-tui/src/tui/events.rs b/bws-tui/src/tui/events.rs index beee945..13fe47e 100644 --- a/bws-tui/src/tui/events.rs +++ b/bws-tui/src/tui/events.rs @@ -134,8 +134,8 @@ pub(super) fn event_loop( app.action_idx = (app.action_idx + 1).min(ACTIONS.len() - 1); } } - KeyCode::Enter => run_action(app), - KeyCode::Char('c') => copy_action(app), + KeyCode::Enter => run_action(app, term.backend_mut()), + KeyCode::Char('c') => copy_action(app, term.backend_mut()), KeyCode::Char('r') => app.revealed = !app.revealed, KeyCode::Char('e') => edit_action(app), KeyCode::Char('d') => app.mode = Mode::ConfirmDelete, diff --git a/bws-tui/src/tui/tests.rs b/bws-tui/src/tui/tests.rs index 65c8cb1..09e23c7 100644 --- a/bws-tui/src/tui/tests.rs +++ b/bws-tui/src/tui/tests.rs @@ -1,5 +1,23 @@ use super::*; use anyhow::anyhow; +use std::ffi::OsStr; + +#[cfg(target_os = "linux")] +#[test] +fn headless_copy_succeeds_without_a_display_server() { + let mut app = App::new(vec![]); + let mut output = Vec::new(); + let result = copy_value( + &mut app, + "dummy-clipboard-value".to_string(), + &mut output, + false, + true, + ); + assert_eq!(result.unwrap(), CopyMethod::Terminal); + assert_eq!(output, b"\x1b]52;c;ZHVtbXktY2xpcGJvYXJkLXZhbHVl\x07"); + assert!(app.clipboard_clear_at.is_none()); +} #[test] fn clipboard_clear_only_targets_the_value_hush_copied() { @@ -65,3 +83,154 @@ fn terminal_cleanup_reports_every_failure() { assert!(error.contains("raw failed")); assert!(error.contains("screen failed")); } + +#[test] +fn osc52_encodes_utf8_and_flushes() { + #[derive(Default)] + struct Output { + bytes: Vec, + flushes: usize, + } + + impl std::io::Write for Output { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flushes += 1; + Ok(()) + } + } + + let mut output = Output::default(); + write_osc52("café", &mut output).unwrap(); + assert_eq!(output.bytes, b"\x1b]52;c;Y2Fmw6k=\x07"); + assert_eq!(output.flushes, 1); +} + +#[test] +fn display_detection_covers_x11_and_wayland() { + assert!(!has_display_server(None, None)); + assert!(!has_display_server(Some(OsStr::new("")), None)); + assert!(has_display_server(Some(OsStr::new(":0")), None)); + assert!(has_display_server(None, Some(OsStr::new("wayland-0")))); +} + +#[test] +fn terminal_copy_failure_names_both_clipboards() { + struct FailingWriter; + + impl std::io::Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("terminal write failed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut app = App::new(vec![]); + let error = copy_value( + &mut app, + "dummy-clipboard-value".to_string(), + &mut FailingWriter, + false, + true, + ) + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("native clipboard unavailable: no display server")); + assert!(message.contains("terminal clipboard OSC 52 failed")); + assert!(!message.contains("dummy-clipboard-value")); +} + +#[test] +fn osc52_rejects_oversized_values_without_writing() { + let mut output = Vec::new(); + let value = "x".repeat(128 * 1024 + 1); + let error = write_osc52(&value, &mut output).unwrap_err(); + assert!(error.to_string().contains("exceeds 128 KiB")); + assert!(output.is_empty()); +} + +#[cfg(target_os = "linux")] +#[test] +fn failed_native_and_terminal_paths_are_both_reported() { + if should_try_native_clipboard() { + return; + } + + struct FailingWriter; + + impl std::io::Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("terminal write failed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut app = App::new(vec![]); + let error = copy_value( + &mut app, + "dummy-clipboard-value".to_string(), + &mut FailingWriter, + true, + true, + ) + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("native clipboard is unavailable")); + assert!(message.contains("terminal clipboard OSC 52 failed")); + assert!(!message.contains("dummy-clipboard-value")); +} + +#[test] +fn terminal_fallback_preserves_an_earlier_native_clear() { + let mut app = App::new(vec![]); + let clear_at = Instant::now() + Duration::from_secs(30); + app.clipboard_value = Some(SecretString::from("prior-copy".to_string())); + app.clipboard_clear_at = Some(clear_at); + + let mut output = Vec::new(); + let method = copy_value( + &mut app, + "dummy-clipboard-value".to_string(), + &mut output, + false, + true, + ) + .unwrap(); + + assert_eq!(method, CopyMethod::Terminal); + assert_eq!(app.clipboard_clear_at, Some(clear_at)); + assert!(clipboard_holds_copied_value( + "prior-copy", + app.clipboard_value.as_ref().unwrap() + )); +} + +#[test] +/// Redirected output must never receive recoverable secret bytes through OSC 52. +fn terminal_fallback_rejects_redirected_output_without_writing() { + let mut app = App::new(vec![]); + let mut output = Vec::new(); + let error = copy_value( + &mut app, + "dummy-clipboard-value".to_string(), + &mut output, + false, + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("stdout is not a terminal")); + assert!(!error.to_string().contains("dummy-clipboard-value")); + assert!(output.is_empty()); + assert!(app.clipboard_value.is_none()); + assert!(app.clipboard_clear_at.is_none()); +}