diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 09:39:32 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 09:39:32 +0200 |
| commit | a76ed355954712e945b2f5da5929ca4c10dea0d2 (patch) | |
| tree | 908712e7f2764015d7526da4bb79d2cdf4311ac1 | |
| parent | 66d824193dc63eeea5f59bc86238f5f6106ae02b (diff) | |
Implement clipboard package (task 357)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/clipboard/clipboard.go | 98 | ||||
| -rw-r--r-- | internal/clipboard/clipboard_test.go | 124 |
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) + } +} |
