diff --git a/README.md b/README.md index 18a6057..68fa368 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ config := &vault.Config{ Type: vault.ProviderTypeExternal, External: &vault.ExternalConfig{ Get: vault.CommandConfig{ - CommandTemplate: "op read --no-newline '{{ref}}'", + CommandTemplate: "op read --no-newline '{{ref}}' && echo", // exactly one trailing newline, which GetSecret strips }, // Where the link registry is kept. StoragePath: "~/.config/flow/vaults", diff --git a/examples/providers/1password.json b/examples/providers/1password.json index c1190d6..e959de5 100644 --- a/examples/providers/1password.json +++ b/examples/providers/1password.json @@ -3,7 +3,7 @@ "type": "external", "external": { "get": { - "cmd": "op read --no-newline '{{ref}}'" + "cmd": "op read --no-newline '{{ref}}' && echo" }, "metadata": { "cmd": "op account list" diff --git a/examples_test.go b/examples_test.go index cd51f02..166dca5 100644 --- a/examples_test.go +++ b/examples_test.go @@ -2,7 +2,9 @@ package vault_test import ( "context" + "os" "path/filepath" + "runtime" "strings" "testing" @@ -137,3 +139,58 @@ func TestShippedExampleProvidersAreReadOnly(t *testing.T) { }) } } + +// The 1Password example must round-trip values exactly through the real command runner. op read +// appends a newline only when the value lacks one, and --no-newline never does, while GetSecret +// strips one trailing newline; so the example has to emit exactly one itself, or a value ending +// in a newline (a kubeconfig, a PEM block) comes back without it. +func TestOnePasswordExampleRoundTripsTrailingNewlines(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake op is a POSIX shell script") + } + + bin := t.TempDir() + fakeOp := `#!/bin/sh +# Mimics op read: prints the value, adding a newline only when it is missing and never with --no-newline. +nonl=false +for a in "$@"; do [ "$a" = "--no-newline" ] && nonl=true; done +printf '%s' "$FAKE_OP_VALUE" +if [ "$nonl" = false ]; then + case "$FAKE_OP_VALUE" in *" +") ;; *) printf '\n' ;; esac +fi +` + if err := os.WriteFile(filepath.Join(bin, "op"), []byte(fakeOp), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + cfg, err := vault.LoadConfigJSON("examples/providers/1password.json") + if err != nil { + t.Fatalf("LoadConfigJSON() error = %v", err) + } + cfg.External.StoragePath = t.TempDir() + provider, err := vault.NewExternalVaultProvider(&cfg) + if err != nil { + t.Fatalf("config was rejected: %v", err) + } + if err := provider.Link("k", "op://Team/Item/field"); err != nil { + t.Fatalf("Link() error = %v", err) + } + + for _, value := range []string{ + "single-line-token", + "apiVersion: v1\nkind: Config\n", + "trailing space ", + "ends with two newlines\n\n", + } { + t.Setenv("FAKE_OP_VALUE", value) + secret, err := provider.GetSecret("k") + if err != nil { + t.Fatalf("GetSecret() error = %v", err) + } + if got := secret.PlainTextString(); got != value { + t.Errorf("GetSecret() = %q, want %q", got, value) + } + } +}