diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/clipboard/clipboard.go | 30 | ||||
| -rw-r--r-- | internal/clipboard/clipboard_test.go | 113 |
2 files changed, 115 insertions, 28 deletions
diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 064a2bf..d89e7ab 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -10,6 +10,14 @@ import ( "regexp" ) +// matchRe captures the first non-whitespace:non-whitespace pair in a string, +// identifying the user and password fields. Compiled once at startup. +var matchRe = regexp.MustCompile(`(\S+):(\S+)`) + +// censorRe replaces every non-whitespace:non-whitespace token with word:CENSORED +// so the password is redacted while the user and context are preserved. +var censorRe = regexp.MustCompile(`(\S+):\S+`) + // Clipboard holds the OS-specific clipboard command to spawn. // The command receives the password via stdin. type Clipboard struct { @@ -31,9 +39,9 @@ func New(gnomeCmd, macosCmd string) *Clipboard { // 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. +// The clipboard command is started and immediately detached (the wait is done +// in a goroutine), matching the Ruby `Process.detach` behaviour so the caller +// is not blocked waiting for the clipboard daemon to exit. func (c *Clipboard) Paste(ctx context.Context, data string) error { user, password, censored, err := extract(data) if err != nil { @@ -62,13 +70,13 @@ func (c *Clipboard) Paste(ctx context.Context, data string) error { } stdin.Close() + // Detach: reap the child in the background so the parent is not blocked. + // This matches the Ruby `Process.detach(pid)` call. + go func() { _ = clipCmd.Wait() }() + // 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 } @@ -79,12 +87,10 @@ func (c *Clipboard) Paste(ctx context.Context, data string) error { // - censored – data with every "word:secret" token replaced by "word:CENSORED" // // Regex mirrors the Ruby: /(\S+):(\S+)/ for matching and substitution. +// Both sides of the match are greedy (\S+), so for multi-colon tokens like +// "user:pass:extra" the split occurs at the last colon, yielding +// user="user:pass" and password="extra" — identical to Ruby's behaviour. 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") diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go index eadccab..c4468f1 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_test.go @@ -3,9 +3,13 @@ package clipboard import ( "context" "os" + "path/filepath" "testing" + "time" ) +// ---- extract --------------------------------------------------------------- + // TestExtract_basic verifies the simplest possible "user:pass" input. func TestExtract_basic(t *testing.T) { user, password, censored, err := extract("user:pass") @@ -41,33 +45,65 @@ func TestExtract_with_other_text(t *testing.T) { } } +// TestExtract_multiple_separate_tokens verifies that matchRe picks the first +// token and censorRe replaces all tokens when multiple user:pass pairs exist. +func TestExtract_multiple_separate_tokens(t *testing.T) { + user, password, censored, err := extract("a:1 b:2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user != "a" { + t.Errorf("user: got %q, want %q", user, "a") + } + if password != "1" { + t.Errorf("password: got %q, want %q", password, "1") + } + // Both tokens must be censored. + if censored != "a:CENSORED b:CENSORED" { + t.Errorf("censored: got %q, want %q", censored, "a:CENSORED b:CENSORED") + } +} + // 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). +// The engine backtracks to find the last colon that still satisfies the pattern, +// so group 1 = "user:pass" and group 2 = "extra". This matches Ruby's behaviour. 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_trailing_colon verifies that a trailing colon with no password +// (e.g. "user:") does not match because \S+ requires at least one character. +func TestExtract_trailing_colon(t *testing.T) { + _, _, _, err := extract("user:") + if err == nil { + t.Fatal("expected error for 'user:' (empty password), got nil") + } +} + +// TestExtract_leading_colon verifies that a leading colon (empty user) does +// not match because \S+ before the colon requires at least one character. +func TestExtract_leading_colon(t *testing.T) { + _, _, _, err := extract(":pass") + if err == nil { + t.Fatal("expected error for ':pass' (empty user), got nil") + } +} + // 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") @@ -84,6 +120,16 @@ func TestExtract_empty(t *testing.T) { } } +// TestExtract_whitespace_only verifies that whitespace-only input returns an error. +func TestExtract_whitespace_only(t *testing.T) { + _, _, _, err := extract(" ") + if err == nil { + t.Fatal("expected error for whitespace-only input, got nil") + } +} + +// ---- New ------------------------------------------------------------------- + // TestNew_darwin verifies that New returns the macOS command when UNAME=Darwin. func TestNew_darwin(t *testing.T) { t.Setenv("UNAME", "Darwin") @@ -95,13 +141,15 @@ func TestNew_darwin(t *testing.T) { // TestNew_linux verifies that New returns the gnome command when UNAME is unset. func TestNew_linux(t *testing.T) { - os.Unsetenv("UNAME") + t.Setenv("UNAME", "") c := New("gpaste-client", "pbcopy") if c.cmd != "gpaste-client" { t.Errorf("cmd: got %q, want %q", c.cmd, "gpaste-client") } } +// ---- Paste ----------------------------------------------------------------- + // TestPaste_empty_cmd verifies that Paste returns an error when no command is configured. func TestPaste_empty_cmd(t *testing.T) { c := &Clipboard{cmd: ""} @@ -111,14 +159,47 @@ func TestPaste_empty_cmd(t *testing.T) { } } -// 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") +// TestPaste_password_reaches_stdin verifies that only the password — not the +// full data string — is written to the clipboard command's stdin. +// A shell script captures stdin to a temp file so we can assert its content. +func TestPaste_password_reaches_stdin(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "clipboard.txt") + script := filepath.Join(t.TempDir(), "capture.sh") + scriptContent := "#!/bin/sh\ncat > " + outFile + "\n" + if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil { + t.Fatalf("WriteFile script: %v", err) + } + + c := &Clipboard{cmd: script} + if err := c.Paste(context.Background(), "user:s3cr3t"); err != nil { + t.Fatalf("Paste: %v", err) + } + + // The clipboard command runs in a detached goroutine. Poll for the output + // file with a short sleep between attempts — in practice the shell script + // exits within a few milliseconds. + for range 100 { + if _, err := os.Stat(outFile); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + + got, err := os.ReadFile(outFile) if err != nil { - t.Fatalf("Paste with 'cat' command failed: %v", err) + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "s3cr3t" { + t.Errorf("clipboard content: got %q, want %q", string(got), "s3cr3t") + } +} + +// TestPaste_bad_command verifies that Paste returns an error when the clipboard +// command does not exist. +func TestPaste_bad_command(t *testing.T) { + c := &Clipboard{cmd: "/nonexistent/command"} + err := c.Paste(context.Background(), "user:pass") + if err == nil { + t.Fatal("expected error for nonexistent command, got nil") } } |
