927 lines
22 KiB
Go
927 lines
22 KiB
Go
package secretstore
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBitwardenCommand = "bw"
|
|
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
|
bitwardenSessionEnvName = "BW_SESSION"
|
|
bitwardenSecretFieldName = "mcp-secret"
|
|
bitwardenServiceFieldName = "mcp-service"
|
|
bitwardenSecretNameFieldName = "mcp-secret-name"
|
|
bitwardenLoaderMessage = "Waiting BitWarden..."
|
|
bitwardenLoaderInterval = 90 * time.Millisecond
|
|
bitwardenLoaderColorBase = "\033[38;5;39m"
|
|
bitwardenLoaderColorWave = "\033[38;5;81m"
|
|
bitwardenLoaderColorFocus = "\033[38;5;117m"
|
|
bitwardenLoaderResetColor = "\033[0m"
|
|
bitwardenLoaderClearLine = "\r\033[2K"
|
|
)
|
|
|
|
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
|
|
type bitwardenInteractiveRunner func(
|
|
command string,
|
|
stdin io.Reader,
|
|
stdout, stderr io.Writer,
|
|
args ...string,
|
|
) ([]byte, error)
|
|
|
|
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
|
|
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
|
|
var startBitwardenLoaderFunc = startBitwardenLoader
|
|
var bitwardenLoaderActive atomic.Bool
|
|
var bitwardenDebugOutput io.Writer = os.Stderr
|
|
|
|
type bitwardenStore struct {
|
|
command string
|
|
serviceName string
|
|
debug bool
|
|
lookupEnv func(string) (string, bool)
|
|
shell string
|
|
cache *bitwardenCache
|
|
}
|
|
|
|
type bitwardenListItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type bitwardenStatusOutput struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
|
|
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
|
|
ServiceName: serviceName,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"secret backend policy %q cannot load persisted bitwarden session for service %q: %w",
|
|
policy,
|
|
serviceName,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
session, _ := os.LookupEnv(bitwardenSessionEnvName)
|
|
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
|
|
store := &bitwardenStore{
|
|
command: command,
|
|
serviceName: serviceName,
|
|
debug: debugEnabled,
|
|
lookupEnv: options.LookupEnv,
|
|
shell: options.Shell,
|
|
cache: newBitwardenCache(bitwardenCacheOptions{
|
|
ServiceName: serviceName,
|
|
Session: session,
|
|
TTL: defaultBitwardenCacheTTL,
|
|
CacheDir: resolveBitwardenCacheDir(serviceName),
|
|
Enabled: cacheEnabled,
|
|
}),
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
func verifyBitwardenCLIReady(options Options) error {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
|
|
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return fmt.Errorf(
|
|
"requires bitwarden CLI command %q in PATH: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
return fmt.Errorf(
|
|
"cannot verify bitwarden CLI command %q: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
if err := EnsureBitwardenReady(options); err != nil {
|
|
return fmt.Errorf(
|
|
"cannot use bitwarden CLI command %q right now: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func EnsureBitwardenReady(options Options) error {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
|
|
|
status, err := readBitwardenStatus(command, debugEnabled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch status {
|
|
case "unauthenticated":
|
|
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
|
case "locked":
|
|
return fmt.Errorf(
|
|
"%w: run `%s` then retry",
|
|
ErrBWLocked,
|
|
unlockCommand,
|
|
)
|
|
case "unlocked":
|
|
lookupEnv := options.LookupEnv
|
|
if lookupEnv == nil {
|
|
lookupEnv = os.LookupEnv
|
|
}
|
|
|
|
session, ok := lookupEnv(bitwardenSessionEnvName)
|
|
if !ok || strings.TrimSpace(session) == "" {
|
|
session, ok = os.LookupEnv(bitwardenSessionEnvName)
|
|
}
|
|
if !ok || strings.TrimSpace(session) == "" {
|
|
return fmt.Errorf(
|
|
"%w: environment variable %q is missing; run `%s` then retry",
|
|
ErrBWLocked,
|
|
bitwardenSessionEnvName,
|
|
unlockCommand,
|
|
)
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf(
|
|
"%w: unsupported bitwarden status %q",
|
|
ErrBWUnavailable,
|
|
status,
|
|
)
|
|
}
|
|
}
|
|
|
|
func readBitwardenStatus(command string, debug bool) (string, error) {
|
|
output, err := runBitwardenCommand(command, debug, nil, "status")
|
|
if err != nil {
|
|
return "", fmt.Errorf("check bitwarden CLI status: %w", err)
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(string(output))
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
|
|
}
|
|
|
|
var status bitwardenStatusOutput
|
|
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
|
|
return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
|
|
}
|
|
|
|
return strings.ToLower(strings.TrimSpace(status.Status)), nil
|
|
}
|
|
|
|
func bitwardenUnlockRemediation(command, shellHint string) string {
|
|
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
|
|
|
|
switch detectShellFlavor(shellHint) {
|
|
case "fish":
|
|
return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand)
|
|
case "powershell":
|
|
return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand)
|
|
case "cmd":
|
|
return fmt.Sprintf(
|
|
"for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i",
|
|
unlockCommand,
|
|
bitwardenSessionEnvName,
|
|
)
|
|
default:
|
|
return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand)
|
|
}
|
|
}
|
|
|
|
func detectShellFlavor(shellHint string) string {
|
|
raw := strings.TrimSpace(shellHint)
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("SHELL"))
|
|
}
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("COMSPEC"))
|
|
}
|
|
if raw == "" && runtime.GOOS == "windows" {
|
|
return "powershell"
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(raw))
|
|
base := strings.ToLower(filepath.Base(lower))
|
|
|
|
switch {
|
|
case strings.Contains(lower, "powershell"),
|
|
strings.Contains(lower, "pwsh"),
|
|
base == "powershell",
|
|
base == "powershell.exe",
|
|
base == "pwsh",
|
|
base == "pwsh.exe":
|
|
return "powershell"
|
|
case strings.Contains(lower, "fish"), base == "fish":
|
|
return "fish"
|
|
case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe":
|
|
return "cmd"
|
|
default:
|
|
return "posix"
|
|
}
|
|
}
|
|
|
|
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
|
secretName := s.scopedName(name)
|
|
if err := s.ensureReady(); err != nil {
|
|
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
|
|
}
|
|
|
|
item, payload, err := s.findItem(secretName, name)
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
template, err := s.itemTemplate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret)
|
|
encoded, err := s.encodePayload(template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("create bitwarden item for secret %q", name),
|
|
nil,
|
|
"create",
|
|
"item",
|
|
encoded,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
|
|
encoded, err := s.encodePayload(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("update bitwarden item for secret %q", name),
|
|
nil,
|
|
"edit",
|
|
"item",
|
|
item.ID,
|
|
encoded,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
|
secretName := s.scopedName(name)
|
|
if s.cache != nil {
|
|
if secret, ok := s.cache.load(name, secretName); ok {
|
|
return secret, nil
|
|
}
|
|
}
|
|
|
|
if err := s.ensureReady(); err != nil {
|
|
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
|
|
}
|
|
|
|
_, payload, err := s.findItem(secretName, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
secret, ok := readBitwardenSecret(payload)
|
|
if !ok {
|
|
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) DeleteSecret(name string) error {
|
|
secretName := s.scopedName(name)
|
|
if err := s.ensureReady(); err != nil {
|
|
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
|
|
}
|
|
|
|
item, _, err := s.findItem(secretName, name)
|
|
if errors.Is(err, ErrNotFound) {
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("delete bitwarden item for secret %q", name),
|
|
nil,
|
|
"delete",
|
|
"item",
|
|
item.ID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *bitwardenStore) scopedName(name string) string {
|
|
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
|
}
|
|
|
|
func (s *bitwardenStore) ensureReady() error {
|
|
return verifyBitwardenCLIReady(Options{
|
|
BitwardenCommand: s.command,
|
|
BitwardenDebug: s.debug,
|
|
LookupEnv: s.lookupEnv,
|
|
Shell: s.shell,
|
|
})
|
|
}
|
|
|
|
type bitwardenResolvedItem struct {
|
|
item bitwardenListItem
|
|
payload map[string]any
|
|
}
|
|
|
|
func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) {
|
|
output, err := s.execute(
|
|
fmt.Sprintf("list bitwarden items for secret %q", secretName),
|
|
nil,
|
|
"list",
|
|
"items",
|
|
"--search",
|
|
secretName,
|
|
)
|
|
if err != nil {
|
|
return bitwardenListItem{}, nil, err
|
|
}
|
|
if strings.TrimSpace(string(output)) == "" {
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
}
|
|
|
|
var items []bitwardenListItem
|
|
if err := json.Unmarshal(output, &items); err != nil {
|
|
return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
|
|
}
|
|
|
|
matches := make([]bitwardenListItem, 0, len(items))
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.Name) != secretName {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(item.ID) == "" {
|
|
continue
|
|
}
|
|
matches = append(matches, item)
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
}
|
|
|
|
markedMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
|
legacyMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
|
for _, item := range matches {
|
|
payload, err := s.itemByID(item.ID)
|
|
if err != nil {
|
|
return bitwardenListItem{}, nil, err
|
|
}
|
|
|
|
resolved := bitwardenResolvedItem{
|
|
item: item,
|
|
payload: payload,
|
|
}
|
|
|
|
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
|
|
markedMatches = append(markedMatches, resolved)
|
|
continue
|
|
}
|
|
legacyMatches = append(legacyMatches, resolved)
|
|
}
|
|
|
|
switch len(markedMatches) {
|
|
case 0:
|
|
switch len(legacyMatches) {
|
|
case 0:
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
case 1:
|
|
return legacyMatches[0].item, legacyMatches[0].payload, nil
|
|
default:
|
|
return bitwardenListItem{}, nil, fmt.Errorf(
|
|
"multiple legacy bitwarden items match secret %q for service %q",
|
|
secretName,
|
|
s.serviceName,
|
|
)
|
|
}
|
|
case 1:
|
|
return markedMatches[0].item, markedMatches[0].payload, nil
|
|
default:
|
|
return bitwardenListItem{}, nil, fmt.Errorf(
|
|
"multiple bitwarden items share marker for secret %q and service %q",
|
|
secretName,
|
|
s.serviceName,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *bitwardenStore) itemTemplate() (map[string]any, error) {
|
|
output, err := s.execute("load bitwarden item template", nil, "get", "template", "item")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output, &payload); err != nil {
|
|
return nil, fmt.Errorf("decode bitwarden item template: %w", err)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) itemByID(id string) (map[string]any, error) {
|
|
trimmedID := strings.TrimSpace(id)
|
|
if trimmedID == "" {
|
|
return nil, errors.New("bitwarden item id must not be empty")
|
|
}
|
|
|
|
output, err := s.execute(
|
|
fmt.Sprintf("read bitwarden item %q", trimmedID),
|
|
nil,
|
|
"get",
|
|
"item",
|
|
trimmedID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output, &payload); err != nil {
|
|
return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode bitwarden payload: %w", err)
|
|
}
|
|
|
|
output, err := s.execute("encode bitwarden payload", raw, "encode")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
encoded := strings.TrimSpace(string(output))
|
|
if encoded == "" {
|
|
return "", errors.New("bitwarden CLI returned an empty encoded payload")
|
|
}
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
|
|
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", operation, err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) {
|
|
payload["type"] = 2
|
|
payload["name"] = secretName
|
|
payload["notes"] = strings.TrimSpace(label)
|
|
payload["secureNote"] = map[string]any{"type": 0}
|
|
payload["fields"] = []map[string]any{
|
|
{
|
|
"name": bitwardenSecretFieldName,
|
|
"value": secret,
|
|
"type": 1,
|
|
},
|
|
{
|
|
"name": bitwardenServiceFieldName,
|
|
"value": strings.TrimSpace(serviceName),
|
|
"type": 0,
|
|
},
|
|
{
|
|
"name": bitwardenSecretNameFieldName,
|
|
"value": strings.TrimSpace(rawSecretName),
|
|
"type": 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
func readBitwardenSecret(payload map[string]any) (string, bool) {
|
|
return readBitwardenField(payload, bitwardenSecretFieldName)
|
|
}
|
|
|
|
func readBitwardenField(payload map[string]any, fieldName string) (string, bool) {
|
|
rawFields, ok := payload["fields"]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
fields, ok := rawFields.([]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
for _, rawField := range fields {
|
|
field, ok := rawField.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name, _ := field["name"].(string)
|
|
if strings.TrimSpace(name) != strings.TrimSpace(fieldName) {
|
|
continue
|
|
}
|
|
|
|
value, ok := field["value"].(string)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
return value, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool {
|
|
markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) &&
|
|
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
|
|
}
|
|
|
|
func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) {
|
|
if debug {
|
|
logBitwardenCommand(command, args...)
|
|
}
|
|
return runBitwardenCLI(command, stdin, args...)
|
|
}
|
|
|
|
func runBitwardenInteractiveCommand(
|
|
command string,
|
|
debug bool,
|
|
stdin io.Reader,
|
|
stdout, stderr io.Writer,
|
|
args ...string,
|
|
) ([]byte, error) {
|
|
if debug {
|
|
logBitwardenCommand(command, args...)
|
|
}
|
|
return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...)
|
|
}
|
|
|
|
func isBitwardenDebugEnabled(explicit bool) bool {
|
|
if explicit {
|
|
return true
|
|
}
|
|
|
|
raw, ok := os.LookupEnv(bitwardenDebugEnvName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes", "y", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func logBitwardenCommand(command string, args ...string) {
|
|
writer := bitwardenDebugOutput
|
|
if writer == nil {
|
|
return
|
|
}
|
|
|
|
renderedArgs := sanitizeBitwardenDebugArgs(args)
|
|
if len(renderedArgs) == 0 {
|
|
_, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command))
|
|
return
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(
|
|
writer,
|
|
"[bitwarden debug] %s %s\n",
|
|
strings.TrimSpace(command),
|
|
strings.Join(renderedArgs, " "),
|
|
)
|
|
}
|
|
|
|
func sanitizeBitwardenDebugArgs(args []string) []string {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rendered := make([]string, len(args))
|
|
for idx, arg := range args {
|
|
rendered[idx] = strings.TrimSpace(arg)
|
|
}
|
|
|
|
if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" {
|
|
rendered[2] = "<redacted>"
|
|
}
|
|
if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" {
|
|
rendered[3] = "<redacted>"
|
|
}
|
|
|
|
return rendered
|
|
}
|
|
|
|
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
stopLoader := startBitwardenLoaderFunc()
|
|
defer stopLoader()
|
|
|
|
cmd := exec.Command(command, args...)
|
|
if stdin != nil {
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String())
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func executeBitwardenCLIInteractive(
|
|
command string,
|
|
stdin io.Reader,
|
|
stdout, stderr io.Writer,
|
|
args ...string,
|
|
) ([]byte, error) {
|
|
cmd := exec.Command(command, args...)
|
|
if stdin != nil {
|
|
cmd.Stdin = stdin
|
|
}
|
|
|
|
var stdoutBuffer bytes.Buffer
|
|
if stdout == nil {
|
|
cmd.Stdout = &stdoutBuffer
|
|
} else {
|
|
cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer)
|
|
}
|
|
|
|
var stderrBuffer bytes.Buffer
|
|
if stderr == nil {
|
|
cmd.Stderr = &stderrBuffer
|
|
} else {
|
|
cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer)
|
|
}
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String())
|
|
}
|
|
|
|
return stdoutBuffer.Bytes(), nil
|
|
}
|
|
|
|
func startBitwardenLoader() func() {
|
|
if !shouldShowBitwardenLoader() {
|
|
return func() {}
|
|
}
|
|
if !bitwardenLoaderActive.CompareAndSwap(false, true) {
|
|
return func() {}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(stopped)
|
|
|
|
ticker := time.NewTicker(bitwardenLoaderInterval)
|
|
defer ticker.Stop()
|
|
|
|
phase := 0
|
|
for {
|
|
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase))
|
|
phase++
|
|
|
|
select {
|
|
case <-done:
|
|
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine)
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}()
|
|
|
|
var stopOnce sync.Once
|
|
return func() {
|
|
stopOnce.Do(func() {
|
|
close(done)
|
|
<-stopped
|
|
bitwardenLoaderActive.Store(false)
|
|
})
|
|
}
|
|
}
|
|
|
|
func shouldShowBitwardenLoader() bool {
|
|
term := strings.TrimSpace(os.Getenv("TERM"))
|
|
if term == "" || strings.EqualFold(term, "dumb") {
|
|
return false
|
|
}
|
|
|
|
info, err := os.Stdout.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return (info.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func bitwardenLoaderFrame(phase int) string {
|
|
chars := []rune(bitwardenLoaderMessage)
|
|
if len(chars) == 0 {
|
|
return bitwardenLoaderClearLine
|
|
}
|
|
|
|
waveIndex := phase % len(chars)
|
|
if waveIndex < 0 {
|
|
waveIndex += len(chars)
|
|
}
|
|
|
|
var frame strings.Builder
|
|
frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor))
|
|
frame.WriteString(bitwardenLoaderClearLine)
|
|
|
|
for idx, char := range chars {
|
|
frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars)))
|
|
frame.WriteRune(char)
|
|
}
|
|
|
|
frame.WriteString(bitwardenLoaderResetColor)
|
|
return frame.String()
|
|
}
|
|
|
|
func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string {
|
|
if length <= 1 {
|
|
return bitwardenLoaderColorFocus
|
|
}
|
|
|
|
distance := idx - waveIndex
|
|
if distance < 0 {
|
|
distance = -distance
|
|
}
|
|
if wrapped := length - distance; wrapped < distance {
|
|
distance = wrapped
|
|
}
|
|
|
|
switch distance {
|
|
case 0:
|
|
return bitwardenLoaderColorFocus
|
|
case 1:
|
|
return bitwardenLoaderColorWave
|
|
default:
|
|
return bitwardenLoaderColorBase
|
|
}
|
|
}
|
|
|
|
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
|
|
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
|
|
classification := classifyBitwardenError(detail)
|
|
if classification == nil {
|
|
classification = ErrBWUnavailable
|
|
}
|
|
|
|
wrapped := errors.Join(classification, err)
|
|
if strings.TrimSpace(detail) == "" {
|
|
return wrapped
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s", wrapped, detail)
|
|
}
|
|
|
|
func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string {
|
|
raw := strings.TrimSpace(stderrText)
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(stdoutText)
|
|
}
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
|
|
lines := strings.Split(raw, "\n")
|
|
cleaned := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
|
|
lower := strings.ToLower(trimmed)
|
|
if strings.HasPrefix(trimmed, "at ") ||
|
|
strings.HasPrefix(lower, "node:internal") ||
|
|
strings.HasPrefix(lower, "internal/") ||
|
|
strings.HasPrefix(lower, "npm ") {
|
|
continue
|
|
}
|
|
|
|
cleaned = append(cleaned, trimmed)
|
|
}
|
|
|
|
if len(cleaned) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if len(cleaned) == 1 {
|
|
return cleaned[0]
|
|
}
|
|
|
|
return cleaned[0] + " | " + cleaned[1]
|
|
}
|
|
|
|
func classifyBitwardenError(detail string) error {
|
|
lower := strings.ToLower(strings.TrimSpace(detail))
|
|
|
|
switch {
|
|
case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"):
|
|
return ErrBWNotLoggedIn
|
|
case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"):
|
|
return ErrBWLocked
|
|
case strings.Contains(lower, "failed to fetch"),
|
|
strings.Contains(lower, "econnrefused"),
|
|
strings.Contains(lower, "etimedout"),
|
|
strings.Contains(lower, "unable to connect"),
|
|
strings.Contains(lower, "network"):
|
|
return ErrBWUnavailable
|
|
default:
|
|
return nil
|
|
}
|
|
}
|