summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 09:39:32 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 09:39:32 +0200
commita76ed355954712e945b2f5da5929ca4c10dea0d2 (patch)
tree908712e7f2764015d7526da4bb79d2cdf4311ac1
parent66d824193dc63eeea5f59bc86238f5f6106ae02b (diff)
Implement clipboard package (task 357)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/clipboard/clipboard.go98
-rw-r--r--internal/clipboard/clipboard_test.go124
2 files changed, 221 insertions, 1 deletions
diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go
index 6635537..064a2bf 100644
--- a/internal/clipboard/clipboard.go
+++ b/internal/clipboard/clipboard.go
@@ -1,2 +1,98 @@
-// Package clipboard provides clipboard read/write access for geheim.
+// Package clipboard pipes a password field to the OS clipboard command.
+// On macOS (UNAME=Darwin) it uses pbcopy; on Linux it uses gpaste-client.
package clipboard
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+)
+
+// Clipboard holds the OS-specific clipboard command to spawn.
+// The command receives the password via stdin.
+type Clipboard struct {
+ cmd string
+}
+
+// New returns a Clipboard configured for the current platform.
+// If UNAME == "Darwin" the macosCmd is used; otherwise gnomeCmd is used.
+// This mirrors the Ruby: ENV['UNAME'] == 'Darwin' ? Config.macos_clipboard_cmd : Config.gnome_clipboard_cmd
+func New(gnomeCmd, macosCmd string) *Clipboard {
+ cmd := gnomeCmd
+ if os.Getenv("UNAME") == "Darwin" {
+ cmd = macosCmd
+ }
+ return &Clipboard{cmd: cmd}
+}
+
+// Paste extracts the password from data, pipes it to the clipboard command,
+// and prints the censored form of data to stdout so the operator can see
+// contextual information without the secret being visible.
+//
+// The Ruby implementation spawns the clipboard command with an IO pipe and
+// detaches the child process. Here we use os/exec with a StdinPipe and
+// Wait() for simplicity; the behaviour is equivalent for interactive use.
+func (c *Clipboard) Paste(ctx context.Context, data string) error {
+ user, password, censored, err := extract(data)
+ if err != nil {
+ return err
+ }
+
+ if c.cmd == "" {
+ return fmt.Errorf("can't paste to clipboard")
+ }
+
+ // Spawn the clipboard command; the password is written to its stdin.
+ clipCmd := exec.CommandContext(ctx, c.cmd) //nolint:gosec // cmd is caller-supplied config
+
+ stdin, err := clipCmd.StdinPipe()
+ if err != nil {
+ return fmt.Errorf("opening stdin pipe for clipboard command: %w", err)
+ }
+
+ if err := clipCmd.Start(); err != nil {
+ return fmt.Errorf("starting clipboard command %q: %w", c.cmd, err)
+ }
+
+ // Write only the password — never the full data — to the clipboard.
+ if _, err := fmt.Fprint(stdin, password); err != nil {
+ return fmt.Errorf("writing password to clipboard command stdin: %w", err)
+ }
+ stdin.Close()
+
+ // Print the censored representation so the operator sees context.
+ fmt.Println(censored)
+
+ if err := clipCmd.Wait(); err != nil {
+ return fmt.Errorf("clipboard command exited with error: %w", err)
+ }
+
+ fmt.Printf("> Pasted password for user '%s' to the clipboard\n", user)
+ return nil
+}
+
+// extract parses data for the first "user:password" token and returns:
+// - user – everything before the colon in the first match
+// - password – everything after the colon in the first match
+// - censored – data with every "word:secret" token replaced by "word:CENSORED"
+//
+// Regex mirrors the Ruby: /(\S+):(\S+)/ for matching and substitution.
+func extract(data string) (user, password, censored string, err error) {
+ // matchRe captures the first non-whitespace:non-whitespace pair.
+ matchRe := regexp.MustCompile(`(\S+):(\S+)`)
+ // censorRe replaces every such pair; the first capture group is kept.
+ censorRe := regexp.MustCompile(`(\S+):\S+`)
+
+ parts := matchRe.FindStringSubmatch(data)
+ if parts == nil {
+ return "", "", "", fmt.Errorf("no user:password pattern found in data")
+ }
+
+ user = parts[1]
+ password = parts[2]
+ // Replace all occurrences with "$1:CENSORED", preserving the word before the colon.
+ censored = censorRe.ReplaceAllString(data, "${1}:CENSORED")
+ return user, password, censored, nil
+}
diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go
new file mode 100644
index 0000000..eadccab
--- /dev/null
+++ b/internal/clipboard/clipboard_test.go
@@ -0,0 +1,124 @@
+package clipboard
+
+import (
+ "context"
+ "os"
+ "testing"
+)
+
+// TestExtract_basic verifies the simplest possible "user:pass" input.
+func TestExtract_basic(t *testing.T) {
+ user, password, censored, err := extract("user:pass")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if user != "user" {
+ t.Errorf("user: got %q, want %q", user, "user")
+ }
+ if password != "pass" {
+ t.Errorf("password: got %q, want %q", password, "pass")
+ }
+ if censored != "user:CENSORED" {
+ t.Errorf("censored: got %q, want %q", censored, "user:CENSORED")
+ }
+}
+
+// TestExtract_with_other_text verifies that surrounding text is preserved and
+// only the secret portion is replaced.
+func TestExtract_with_other_text(t *testing.T) {
+ user, password, censored, err := extract("login user:secret123 notes")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if user != "user" {
+ t.Errorf("user: got %q, want %q", user, "user")
+ }
+ if password != "secret123" {
+ t.Errorf("password: got %q, want %q", password, "secret123")
+ }
+ if censored != "login user:CENSORED notes" {
+ t.Errorf("censored: got %q, want %q", censored, "login user:CENSORED notes")
+ }
+}
+
+// TestExtract_multiple_colons verifies the greedy behaviour of \S+:\S+ when
+// there are multiple colons in a single whitespace-delimited token.
+//
+// With input "user:pass:extra", the regex (\S+):(\S+) is greedy on both sides.
+// Because \S+ before the colon can match anything that is not whitespace, the
+// engine backtracks to find the last colon that still satisfies the pattern,
+// so group 1 = "user:pass" and group 2 = "extra". The entire whitespace token
+// is replaced with one "user:pass:CENSORED" in the censored output, which
+// matches the Ruby gsub behaviour (one replacement per token).
+func TestExtract_multiple_colons(t *testing.T) {
+ user, password, censored, err := extract("user:pass:extra")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // \S+ before the colon is greedy: it consumes "user:pass", leaving "extra".
+ if user != "user:pass" {
+ t.Errorf("user: got %q, want %q", user, "user:pass")
+ }
+ if password != "extra" {
+ t.Errorf("password: got %q, want %q", password, "extra")
+ }
+ // The entire token is one \S+ run so the replacement is applied once.
+ if censored != "user:pass:CENSORED" {
+ t.Errorf("censored: got %q, want %q", censored, "user:pass:CENSORED")
+ }
+}
+
+// TestExtract_no_match verifies that an error is returned when no colon token exists.
+func TestExtract_no_match(t *testing.T) {
+ _, _, _, err := extract("no colon here")
+ if err == nil {
+ t.Fatal("expected error for input without colon, got nil")
+ }
+}
+
+// TestExtract_empty verifies that an empty string returns an error.
+func TestExtract_empty(t *testing.T) {
+ _, _, _, err := extract("")
+ if err == nil {
+ t.Fatal("expected error for empty input, got nil")
+ }
+}
+
+// TestNew_darwin verifies that New returns the macOS command when UNAME=Darwin.
+func TestNew_darwin(t *testing.T) {
+ t.Setenv("UNAME", "Darwin")
+ c := New("gpaste-client", "pbcopy")
+ if c.cmd != "pbcopy" {
+ t.Errorf("cmd: got %q, want %q", c.cmd, "pbcopy")
+ }
+}
+
+// TestNew_linux verifies that New returns the gnome command when UNAME is unset.
+func TestNew_linux(t *testing.T) {
+ os.Unsetenv("UNAME")
+ c := New("gpaste-client", "pbcopy")
+ if c.cmd != "gpaste-client" {
+ t.Errorf("cmd: got %q, want %q", c.cmd, "gpaste-client")
+ }
+}
+
+// TestPaste_empty_cmd verifies that Paste returns an error when no command is configured.
+func TestPaste_empty_cmd(t *testing.T) {
+ c := &Clipboard{cmd: ""}
+ err := c.Paste(context.Background(), "user:pass")
+ if err == nil {
+ t.Fatal("expected error for empty cmd, got nil")
+ }
+}
+
+// TestPaste_with_cat uses "cat" as a stand-in clipboard command to verify that
+// Paste runs end-to-end without error on the current platform.
+// "cat" reads stdin and exits 0, which is sufficient to exercise the full
+// Paste flow without requiring an actual clipboard daemon.
+func TestPaste_with_cat(t *testing.T) {
+ c := &Clipboard{cmd: "cat"}
+ err := c.Paste(context.Background(), "user:pass")
+ if err != nil {
+ t.Fatalf("Paste with 'cat' command failed: %v", err)
+ }
+}