diff --git a/.gitignore b/.gitignore index 0b2d79e..adf1610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /attic/ /cmd/pago/pago +/cmd/pago-agent/pago-agent /dist/ /help /pago diff --git a/README.md b/README.md index b152f3c..e5a0145 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Once Go is installed on your system, run the following command: ``` go install dbohdan.com/pago/cmd/pago@latest +go install dbohdan.com/pago/cmd/pago-agent@latest ``` Shell completion files for Bash and fish are available in [`completions/`](completions/). @@ -183,17 +184,19 @@ The agent keeps your identities in memory to avoid repeated password prompts. pago show foo/bar # Start manually. -pago agent start +pago-agent start # Run without an agent. pago -s '' show foo/bar # Shut down. -pago agent stop +pago-agent stop ``` ### Environment variables +- `PAGO_AGENT`: + The agent executable path - `PAGO_CLIP`: The command to use to copy the password to the clipboard. The default differs by platform. diff --git a/Taskfile.yml b/Taskfile.yml index ddd8fbf..968b36f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,8 +1,9 @@ version: '3' vars: - cmd_dir: cmd/pago ext: '{{if eq OS "windows"}}.exe{{end}}' + pago_agent_dir: cmd/pago-agent + pago_dir: cmd/pago env: CGO_ENABLED: 0 @@ -21,34 +22,46 @@ tasks: desc: 'Build all components' deps: - build_pago + - build_pago_agent build_pago: - dir: '{{.cmd_dir}}' + dir: '{{.pago_dir}}' desc: 'Build the pago binary' cmds: - cmd: go build -trimpath sources: - '*.go' - - '../../*.go' + - '../**/*.go' generates: - pago + build_pago_agent: + dir: '{{.pago_agent_dir}}' + desc: 'Build the pago agent binary' + cmds: + - cmd: go build -trimpath + sources: + - '*.go' + - '../**/*.go' + generates: + - pago-agent + clean: - dir: '{{.cmd_dir}}' desc: 'Clean up binaries' cmds: - - rm -f pago pago.exe + - rm -f {{.pago_dir}}/pago{{.ext}} {{.pago_agent_dir}}/pago-agent{{.ext}} release: desc: 'Prepare a release' deps: - build_pago cmds: - - VERSION=$({{.cmd_dir}}/pago{{.ext}} version) go run script/release.go + - VERSION=$({{.pago_dir}}/pago{{.ext}} version) go run script/release.go test: desc: 'Run tests' deps: - build_pago + - build_pago_agent cmds: - go test ./... diff --git a/cmd/pago/agent.go b/agent/agent.go similarity index 56% rename from cmd/pago/agent.go rename to agent/agent.go index 3620451..7842bdf 100644 --- a/cmd/pago/agent.go +++ b/agent/agent.go @@ -1,11 +1,9 @@ -//go:build !windows - // pago - a command-line password manager. // // License: MIT. // See the file LICENSE. -package main +package agent import ( "bytes" @@ -19,136 +17,56 @@ import ( "syscall" "dbohdan.com/pago" + "dbohdan.com/pago/crypto" "filippo.io/age" "github.com/tidwall/redcon" "github.com/valkey-io/valkey-go" ) -var defaultSocket = filepath.Join(defaultCacheDir, agentSocketPath) - -func (cmd *RestartCmd) Run(config *Config) error { - if config.Verbose { - printRepr(cmd) - } - - if err := pago.LockMemory(); err != nil { - return err - } - - _, _ = messageAgent(config.Socket, "SHUTDOWN") - - identitiesText, err := decryptIdentities(config.Identities) - if err != nil { - return err - } - - return startAgentProcess(config.Socket, identitiesText) -} - -func (cmd *RunCmd) Run(config *Config) error { - if config.Verbose { - printRepr(cmd) - } - - if err := pago.LockMemory(); err != nil { - return err - } - - return runAgent(config.Socket) -} - -func (cmd *StartCmd) Run(config *Config) error { - if config.Verbose { - printRepr(cmd) - } - - if err := pago.LockMemory(); err != nil { - return err - } - - if err := pingAgent(config.Socket); err == nil { - return fmt.Errorf("found agent responding on socket") - } - - identitiesText, err := decryptIdentities(config.Identities) - if err != nil { - return err - } - - return startAgentProcess(config.Socket, identitiesText) -} - -func (cmd *StatusCmd) Run(config *Config) error { - if config.Verbose { - printRepr(cmd) - } - - err := pingAgent(config.Socket) - if err == nil { - fmt.Println("Ping successful") - os.Exit(0) - } else { - fmt.Println("Failed to ping agent") - os.Exit(1) - } - - return nil -} - -func (cmd *StopCmd) Run(config *Config) error { - if config.Verbose { - printRepr(cmd) - } - - _, err := messageAgent(config.Socket, "SHUTDOWN") - return err -} - -func startAgentProcess(agentSocket, identitiesText string) error { - // The agent is the same executable. - exe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to get executable path: %v", err) +func StartProcess(executable string, mlock bool, socket, identitiesText string) error { + mlockFlag := "--mlock" + if !mlock { + mlockFlag = "--no-mlock" } - cmd := exec.Command(exe, "agent", "run", "--socket", agentSocket) + cmd := exec.Command(executable, "run", mlockFlag, "--socket", socket) // Start the process in the background. if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start agent: %v", err) } - _ = os.Remove(agentSocket) + _ = os.Remove(socket) // Don't wait for the process to finish. go func() { _ = cmd.Wait() }() - if err := waitUntilAvailable(agentSocket, waitForSocket); err != nil { + if err := pago.WaitUntilAvailable(socket, pago.WaitForSocket); err != nil { return fmt.Errorf("timed out waiting for agent socket") } - _, err = messageAgent(agentSocket, "IDENTITIES", identitiesText) + _, err := Message(socket, "IDENTITIES", identitiesText) return err } -func runAgent(agentSocket string) error { - if err := pingAgent(agentSocket); err == nil { +func Run(socket string) error { + if err := Ping(socket); err == nil { return fmt.Errorf("found agent responding on socket") } - socketDir := filepath.Dir(agentSocket) - if err := os.MkdirAll(socketDir, dirPerms); err != nil { + socketDir := filepath.Dir(socket) + if err := os.MkdirAll(socketDir, pago.DirPerms); err != nil { return fmt.Errorf("failed to create socket directory: %v", err) } - os.Remove(agentSocket) + os.Remove(socket) identities := []age.Identity{} srv := redcon.NewServerNetwork( "unix", - agentSocket, + socket, func(conn redcon.Conn, cmd redcon.Command) { switch strings.ToUpper(string(cmd.Args[0])) { @@ -162,7 +80,7 @@ func runAgent(agentSocket string) error { // Decrypt the data. reader := bytes.NewReader(encryptedData) - decryptedReader, err := wrapDecrypt(reader, identities...) + decryptedReader, err := crypto.WrapDecrypt(reader, identities...) if err != nil { conn.WriteError("ERR failed to decrypt: " + err.Error()) return @@ -218,22 +136,22 @@ func runAgent(agentSocket string) error { return } - if err := os.Chmod(agentSocket, filePerms); err != nil { - exitWithError("failed to set permissions on agent socket: %v", err) + if err := os.Chmod(socket, pago.FilePerms); err != nil { + pago.ExitWithError("failed to set permissions on agent socket: %v", err) } }() return srv.ListenServeAndSignal(errc) } -func messageAgent(agentSocket string, args ...string) (string, error) { +func Message(socket string, args ...string) (string, error) { // Check socket security. - if err := checkSocketSecurity(agentSocket); err != nil { + if err := checkSocketSecurity(socket); err != nil { return "", fmt.Errorf("socket security check failed: %v", err) } // Connect to the server. - opts, err := valkey.ParseURL("unix://" + agentSocket) + opts, err := valkey.ParseURL("unix://" + socket) if err != nil { return "", fmt.Errorf("failed to parse socket URL: %v", err) } @@ -269,23 +187,23 @@ func messageAgent(agentSocket string, args ...string) (string, error) { return string(result), nil } -func pingAgent(agentSocket string) error { - _, err := messageAgent(agentSocket) +func Ping(socket string) error { + _, err := Message(socket) return err } -func decryptWithAgent(agentSocket string, data []byte) (string, error) { - return messageAgent(agentSocket, "DECRYPT", valkey.BinaryString(data)) +func Decrypt(socket string, data []byte) (string, error) { + return Message(socket, "DECRYPT", valkey.BinaryString(data)) } -func checkSocketSecurity(agentSocket string) error { - info, err := os.Stat(agentSocket) +func checkSocketSecurity(socket string) error { + info, err := os.Stat(socket) if err != nil { return fmt.Errorf("failed to stat socket: %v", err) } // Check socket permissions. - if info.Mode().Perm() != filePerms { + if info.Mode().Perm() != pago.FilePerms { return fmt.Errorf("incorrect socket permissions: %v", info.Mode().Perm()) } diff --git a/cmd/pago-agent/main.go b/cmd/pago-agent/main.go new file mode 100644 index 0000000..b69245d --- /dev/null +++ b/cmd/pago-agent/main.go @@ -0,0 +1,84 @@ +// pago - a command-line password manager. +// +// License: MIT. +// See the file LICENSE. + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "dbohdan.com/pago" + "dbohdan.com/pago/agent" + + "github.com/alecthomas/kong" +) + +type CLI struct { + // Global options. + Mlock bool `env:"${MlockEnv}" default:"true" negatable:"" help:"Lock agent memory with mlockall(2) (${env})"` + Socket string `short:"s" env:"${SocketEnv}" default:"${DefaultSocket}" help:"Agent socket path (${env})"` + + // Commands. + Run RunCmd `cmd:"" help:"Run the agent process"` + Version VersionCmd `cmd:"" aliases:"v,ver" help:"Print version number and exit"` +} + +type RunCmd struct{} + +func (cmd *RunCmd) Run(cli *CLI) error { + if cli.Mlock { + if err := LockMemory(); err != nil { + return err + } + } + + socketDir := filepath.Dir(cli.Socket) + if err := os.MkdirAll(socketDir, pago.DirPerms); err != nil { + return fmt.Errorf("failed to create socket directory: %v", err) + } + + return agent.Run(cli.Socket) +} + +type VersionCmd struct{} + +func (cmd *VersionCmd) Run(cli *CLI) error { + fmt.Println(pago.Version) + return nil +} + +func main() { + var cli CLI + parser := kong.Parse(&cli, + kong.Name("pago-agent"), + kong.Description("Password store agent for pago."), + kong.ConfigureHelp(kong.HelpOptions{ + Compact: true, + }), + kong.Exit(func(code int) { + if code == 1 { + code = 2 + } + + os.Exit(code) + }), + kong.Vars{ + "DefaultSocket": pago.DefaultSocket, + + "MlockEnv": pago.MlockEnv, + "SocketEnv": pago.SocketEnv, + }, + ) + + ctx, err := parser.Parse(os.Args[1:]) + if err != nil { + parser.FatalIfErrorf(err) + } + + if err := ctx.Run(&cli); err != nil { + pago.ExitWithError("%v", err) + } +} diff --git a/mlockall.go b/cmd/pago-agent/mlockall.go similarity index 90% rename from mlockall.go rename to cmd/pago-agent/mlockall.go index 7ae929b..67c9749 100644 --- a/mlockall.go +++ b/cmd/pago-agent/mlockall.go @@ -1,9 +1,11 @@ +//go:build !windows + // pago - a command-line password manager. // // License: MIT. // See the file LICENSE. -package pago +package main import ( "fmt" diff --git a/cmd/pago/main.go b/cmd/pago/main.go index 4b12f57..39b9eaf 100644 --- a/cmd/pago/main.go +++ b/cmd/pago/main.go @@ -22,10 +22,15 @@ import ( "time" "dbohdan.com/pago" + "dbohdan.com/pago/agent" + "dbohdan.com/pago/crypto" + "dbohdan.com/pago/editor" + "dbohdan.com/pago/git" + "dbohdan.com/pago/input" + "dbohdan.com/pago/tree" "filippo.io/age" "filippo.io/age/armor" - "github.com/adrg/xdg" "github.com/alecthomas/kong" "github.com/alecthomas/repr" "github.com/anmitsu/go-shlex" @@ -34,13 +39,15 @@ import ( type CLI struct { // Global options. - Confirm bool `env:"${confirmEnv}" default:"true" negatable:"" help:"Enter passwords twice (${env})"` - Dir string `short:"d" env:"${dataDirEnv}" default:"${defaultDataDir}" help:"Store location (${env})"` - Git bool `env:"${gitEnv}" default:"true" negatable:"" help:"Commit to Git (${env})"` - GitEmail string `env:"${gitEmailEnv}" default:"${defaultGitEmail}" help:"Email for Git commits (${env})"` - GitName string `env:"${gitNameEnv}" default:"${defaultGitName}" help:"Name for Git commits (${env})"` - Socket string `short:"s" env:"${socketEnv}" default:"${defaultSocket}" help:"Agent socket path (blank to disable, ${env})"` - Verbose bool `short:"v" hidden:"" help:"Print debugging information"` + AgentExecutable string `short:"a" name:"agent" env:"${AgentEnv}" default:"${DefaultAgent}" help:"Agent executable (${env})"` + Confirm bool `env:"${ConfirmEnv}" default:"true" negatable:"" help:"Enter passwords twice (${env})"` + Dir string `short:"d" env:"${DataDirEnv}" default:"${DefaultDataDir}" help:"Store location (${env})"` + Git bool `env:"${GitEnv}" default:"true" negatable:"" help:"Commit to Git (${env})"` + GitEmail string `env:"${GitEmailEnv}" default:"${GitEmail}" help:"Email for Git commits (${env})"` + GitName string `env:"${GitNameEnv}" default:"${GitName}" help:"Name for Git commits (${env})"` + Mlock bool `env:"${MlockEnv}" default:"true" negatable:"" help:"Lock agent memory with mlockall(2) (${env})"` + Socket string `short:"s" env:"${SocketEnv}" default:"${DefaultSocket}" help:"Agent socket path (blank to disable, ${env})"` + Verbose bool `short:"v" hidden:"" help:"Print debugging information"` // Commands. Add AddCmd `cmd:"" aliases:"a" help:"Create new password entry"` @@ -60,55 +67,31 @@ type CLI struct { } type Config struct { - Confirm bool - DataDir string - Git bool - GitEmail string - GitName string - Home string - Identities string - Recipients string - Socket string - Store string - Verbose bool + AgentExecutable string + Confirm bool + DataDir string + Git bool + GitEmail string + GitName string + Home string + Identities string + Mlock bool + Recipients string + Socket string + Store string + Verbose bool } const ( - agentSocketPath = "socket" - defaultLength = "20" - defaultPattern = "[A-Za-z0-9]" - dirPerms = 0o700 - filePerms = 0o600 - maxStepsPerChar = 1000 - nameInvalidChars = `[\n]` - storePath = "store" - version = "0.9.2" - waitForSocket = 3 * time.Second - - clipEnv = "PAGO_CLIP" - confirmEnv = "PAGO_CONFIRM" - dataDirEnv = "PAGO_DIR" - gitEnv = "PAGO_GIT" - gitEmailEnv = "GIT_AUTHOR_EMAIL" - gitNameEnv = "GIT_AUTHOR_NAME" - lengthEnv = "PAGO_LENGTH" - patternEnv = "PAGO_PATTERN" - socketEnv = "PAGO_SOCK" - timeoutEnv = "PAGO_TIMEOUT" -) - -var ( - defaultCacheDir = filepath.Join(xdg.CacheHome, "pago") - defaultDataDir = filepath.Join(xdg.DataHome, "pago") - defaultGitEmail = "pago password manager" - defaultGitName = "pago@localhost" + maxStepsPerChar = 1000 + storePath = "store" ) type AddCmd struct { Name string `arg:"" help:"Name of the password entry"` - Length int `short:"l" env:"${lengthEnv}" default:"${defaultLength}" help:"Password length (${env})"` - Pattern string `short:"p" env:"${patternEnv}" default:"${defaultPattern}" help:"Password pattern (regular expression, ${env})"` + Length int `short:"l" env:"${LengthEnv}" default:"${DefaultLength}" help:"Password length (${env})"` + Pattern string `short:"p" env:"${PatternEnv}" default:"${DefaultPattern}" help:"Password pattern (regular expression, ${env})"` Force bool `short:"f" help:"Overwrite existing entry"` Input bool `short:"i" help:"Input the password manually" xor:"mode"` @@ -126,7 +109,7 @@ func (cmd *AddCmd) Run(config *Config) error { printRepr(cmd) } - file, err := entryFile(config.Store, cmd.Name) + file, err := pago.EntryFile(config.Store, cmd.Name) if err != nil { return err } @@ -153,7 +136,7 @@ func (cmd *AddCmd) Run(config *Config) error { if cmd.Input || cmd.Random { generate = cmd.Random } else { - generate, err = pago.AskYesNo("Generate a password?") + generate, err = input.AskYesNo("Generate a password?") if err != nil { return err } @@ -162,19 +145,19 @@ func (cmd *AddCmd) Run(config *Config) error { if generate { password, err = generatePassword(cmd.Pattern, cmd.Length) } else { - password, err = pago.ReadNewPassword(config.Confirm) + password, err = input.ReadNewPassword(config.Confirm) } } if err != nil { return err } - if err := saveEntry(config.Recipients, config.Store, cmd.Name, password); err != nil { + if err := crypto.SaveEntry(config.Recipients, config.Store, cmd.Name, password); err != nil { return err } if config.Git { - if err := pago.Commit( + if err := git.Commit( config.Store, config.GitName, config.GitEmail, @@ -191,7 +174,6 @@ func (cmd *AddCmd) Run(config *Config) error { type AgentCmd struct { Restart RestartCmd `cmd:"" help:"Restart the agent process"` - Run RunCmd `cmd:"" help:"Run the agent"` Start StartCmd `cmd:"" help:"Start the agent process"` Status StatusCmd `cmd:"" help:"Check if agent is running"` Stop StopCmd `cmd:"" help:"Stop the agent process"` @@ -199,20 +181,86 @@ type AgentCmd struct { type RestartCmd struct{} -type RunCmd struct{} +func (cmd *RestartCmd) Run(config *Config) error { + if config.Verbose { + printRepr(cmd) + } + + _, _ = agent.Message(config.Socket, "SHUTDOWN") + + identitiesText, err := crypto.DecryptIdentities(config.Identities) + if err != nil { + return err + } + + return agent.StartProcess( + config.AgentExecutable, + config.Mlock, + config.Socket, + identitiesText, + ) +} type StartCmd struct{} +func (cmd *StartCmd) Run(config *Config) error { + if config.Verbose { + printRepr(cmd) + } + + if err := agent.Ping(config.Socket); err == nil { + return fmt.Errorf("found agent responding on socket") + } + + identitiesText, err := crypto.DecryptIdentities(config.Identities) + if err != nil { + return err + } + + return agent.StartProcess( + config.AgentExecutable, + config.Mlock, + config.Socket, + identitiesText, + ) +} + type StatusCmd struct{} +func (cmd *StatusCmd) Run(config *Config) error { + if config.Verbose { + printRepr(cmd) + } + + err := agent.Ping(config.Socket) + if err == nil { + fmt.Println("Ping successful") + os.Exit(0) + } else { + fmt.Println("Failed to ping agent") + os.Exit(1) + } + + return nil +} + type StopCmd struct{} +func (cmd *StopCmd) Run(config *Config) error { + if config.Verbose { + printRepr(cmd) + } + + _, err := agent.Message(config.Socket, "SHUTDOWN") + return err +} + type ClipCmd struct { Name string `arg:"" optional:"" help:"Name of the password entry"` - Command string `short:"c" env:"${clipEnv}" default:"${defaultClip}" help:"Command for copying text from stdin to clipboard (${env})"` + Command string `short:"c" env:"${ClipEnv}" default:"${DefaultClip}" help:"Command for copying text from stdin to clipboard (${env})"` Pick bool `short:"p" help:"Pick entry using fuzzy finder"` - Timeout int `short:"t" env:"${timeoutEnv}" default:"30" help:"Clipboard timeout (0 to disable, ${env})"` + Timeout int `short:"t" env:"${TimeoutEnv}" default:"30" help:"Clipboard timeout (0 to disable, ${env})"` } func copyToClipboard(command string, text string) error { @@ -239,6 +287,42 @@ func englishPlural(singular, plural string, count int) string { return plural } +func decryptEntry(agentExecutable string, agentMlock bool, agentSocket, identities, passwordStore, name string) (string, error) { + if agentSocket == "" { + return crypto.DecryptEntry(identities, passwordStore, name) + } + + file, err := pago.EntryFile(passwordStore, name) + if err != nil { + return "", err + } + + encryptedData, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("failed to read password file: %v", err) + } + + if err := agent.Ping(agentSocket); err != nil { + // Ping failed. + // Attempt to start the agent. + identitiesText, err := crypto.DecryptIdentities(identities) + if err != nil { + return "", err + } + + if err := agent.StartProcess(agentExecutable, agentMlock, agentSocket, identitiesText); err != nil { + return "", fmt.Errorf("failed to start agent: %v", err) + } + } + + password, err := agent.Decrypt(agentSocket, encryptedData) + if err != nil { + return "", err + } + + return password, nil +} + func (cmd *ClipCmd) Run(config *Config) error { if config.Verbose { printRepr(cmd) @@ -246,7 +330,7 @@ func (cmd *ClipCmd) Run(config *Config) error { name := cmd.Name if cmd.Pick { - picked, err := pago.PickEntry(config.Store, name) + picked, err := input.PickEntry(config.Store, name) if err != nil { return err } @@ -260,7 +344,14 @@ func (cmd *ClipCmd) Run(config *Config) error { return fmt.Errorf("entry doesn't exist: %v", name) } - password, err := decryptEntry(config.Socket, config.Identities, config.Store, name) + password, err := decryptEntry( + config.AgentExecutable, + config.Mlock, + config.Socket, + config.Identities, + config.Store, + name, + ) if err != nil { return err } @@ -301,7 +392,7 @@ func (cmd *DeleteCmd) Run(config *Config) error { name := cmd.Name if cmd.Pick { - picked, err := pago.PickEntry(config.Store, name) + picked, err := input.PickEntry(config.Store, name) if err != nil { return err } @@ -316,12 +407,12 @@ func (cmd *DeleteCmd) Run(config *Config) error { } if !cmd.Force { - if choice, err := pago.AskYesNo(fmt.Sprintf("Delete entry '%s'?", name)); !choice || err != nil { + if choice, err := input.AskYesNo(fmt.Sprintf("Delete entry '%s'?", name)); !choice || err != nil { return err } } - file, err := entryFile(config.Store, name) + file, err := pago.EntryFile(config.Store, name) if err != nil { return nil } @@ -342,7 +433,7 @@ func (cmd *DeleteCmd) Run(config *Config) error { } if config.Git { - if err := pago.Commit( + if err := git.Commit( config.Store, config.GitName, config.GitEmail, @@ -370,7 +461,7 @@ func (cmd *EditCmd) Run(config *Config) error { name := cmd.Name if cmd.Pick { - picked, err := pago.PickEntry(config.Store, name) + picked, err := input.PickEntry(config.Store, name) if err != nil { return err } @@ -385,7 +476,14 @@ func (cmd *EditCmd) Run(config *Config) error { if entryExists(config.Store, name) { // Decrypt the existing password. - password, err = decryptEntry(config.Socket, config.Identities, config.Store, name) + password, err = decryptEntry( + config.AgentExecutable, + config.Mlock, + config.Socket, + config.Identities, + config.Store, + name, + ) if err != nil { return err } @@ -393,30 +491,30 @@ func (cmd *EditCmd) Run(config *Config) error { return fmt.Errorf("entry doesn't exist: %v", name) } - text, err := pago.Edit(password, cmd.Save) - if err != nil && !errors.Is(err, pago.CancelError) { + text, err := editor.Edit(password, cmd.Save) + if err != nil && !errors.Is(err, editor.CancelError) { return fmt.Errorf("editor failed: %v", err) } fmt.Println() - if text == password || errors.Is(err, pago.CancelError) { + if text == password || errors.Is(err, editor.CancelError) { fmt.Fprintln(os.Stderr, "No changes made") return nil } // Save the edited password. - if err := saveEntry(config.Recipients, config.Store, name, text); err != nil { + if err := crypto.SaveEntry(config.Recipients, config.Store, name, text); err != nil { return err } - file, err := entryFile(config.Store, cmd.Name) + file, err := pago.EntryFile(config.Store, cmd.Name) if err != nil { return nil } if config.Git { - if err := pago.Commit( + if err := git.Commit( config.Store, config.GitName, config.GitEmail, @@ -455,8 +553,8 @@ func (cmd *FindCmd) Run(config *Config) error { } type GenerateCmd struct { - Length int `short:"l" env:"${lengthEnv}" default:"${defaultLength}" help:"Password length (${env})"` - Pattern string `short:"p" env:"${patternEnv}" default:"${defaultPattern}" help:"Password pattern (regular expression, ${env})"` + Length int `short:"l" env:"${LengthEnv}" default:"${DefaultLength}" help:"Password length (${env})"` + Pattern string `short:"p" env:"${PatternEnv}" default:"${DefaultPattern}" help:"Password pattern (regular expression, ${env})"` } func (cmd *GenerateCmd) Run(config *Config) error { @@ -511,7 +609,7 @@ func (cmd *InitCmd) Run(config *Config) error { var buf bytes.Buffer armorWriter := armor.NewWriter(&buf) - password, err := pago.ReadNewPassword(config.Confirm) + password, err := input.ReadNewPassword(config.Confirm) if err != nil { return fmt.Errorf("failed to read password: %v", err) } @@ -538,24 +636,24 @@ func (cmd *InitCmd) Run(config *Config) error { return fmt.Errorf("failed to close armor writer: %w", err) } - if err := os.MkdirAll(config.Store, dirPerms); err != nil { + if err := os.MkdirAll(config.Store, pago.DirPerms); err != nil { return fmt.Errorf("failed to create store directory: %v", err) } - if err := os.WriteFile(config.Identities, buf.Bytes(), filePerms); err != nil { + if err := os.WriteFile(config.Identities, buf.Bytes(), pago.FilePerms); err != nil { return fmt.Errorf("failed to write identities file: %w", err) } - if err := os.WriteFile(config.Recipients, []byte(identity.Recipient().String()+"\n"), filePerms); err != nil { + if err := os.WriteFile(config.Recipients, []byte(identity.Recipient().String()+"\n"), pago.FilePerms); err != nil { return fmt.Errorf("failed to write recipients file: %w", err) } if config.Git { - if err := pago.InitGitRepo(config.Store); err != nil { + if err := git.InitRepo(config.Store); err != nil { return err } - if err := pago.Commit( + if err := git.Commit( config.Store, config.GitName, config.GitEmail, @@ -597,7 +695,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { // Decrypt the identities once. // This is so we don't have to ask the user for a password repeatedly without using the agent. - identitiesText, err := decryptIdentities(config.Identities) + identitiesText, err := crypto.DecryptIdentities(config.Identities) if err != nil { return err } @@ -610,7 +708,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { // Decrypt each entry using the loaded identities and reencrypt it with the recipients. count := 0 for _, entry := range entries { - file, err := entryFile(config.Store, entry) + file, err := crypto.EntryFile(config.Store, entry) if err != nil { return fmt.Errorf("failed to get path for %q: %v", entry, err) } @@ -620,7 +718,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { return fmt.Errorf("failed to read password file %q: %v", entry, err) } - r, err := wrapDecrypt(bytes.NewReader(encryptedData), ids...) + r, err := crypto.WrapDecrypt(bytes.NewReader(encryptedData), ids...) if err != nil { return fmt.Errorf("failed to decrypt %q: %v", entry, err) } @@ -630,7 +728,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { return fmt.Errorf("failed to read decrypted content from %q: %v", entry, err) } - if err := saveEntry(config.Recipients, config.Store, entry, string(passwordBytes)); err != nil { + if err := crypto.SaveEntry(config.Recipients, config.Store, entry, string(passwordBytes)); err != nil { return fmt.Errorf("failed to reencrypt %q: %v", entry, err) } @@ -642,7 +740,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { if config.Git { files := make([]string, len(entries)) for i, entry := range entries { - file, err := entryFile(config.Store, entry) + file, err := crypto.EntryFile(config.Store, entry) if err != nil { return fmt.Errorf("failed to get path for %q: %v", entry, err) } @@ -650,7 +748,7 @@ func (cmd *RekeyCmd) Run(config *Config) error { files[i] = file } - if err := pago.Commit( + if err := git.Commit( config.Store, config.GitName, config.GitEmail, @@ -671,12 +769,12 @@ func (cmd *RewrapCmd) Run(config *Config) error { printRepr(cmd) } - identitiesText, err := decryptIdentities(config.Identities) + identitiesText, err := crypto.DecryptIdentities(config.Identities) if err != nil { return err } - newPassword, err := pago.ReadNewPassword(config.Confirm) + newPassword, err := input.ReadNewPassword(config.Confirm) if err != nil { return err } @@ -706,7 +804,7 @@ func (cmd *RewrapCmd) Run(config *Config) error { return fmt.Errorf("failed to close armor writer: %w", err) } - if err := os.WriteFile(config.Identities, buf.Bytes(), filePerms); err != nil { + if err := os.WriteFile(config.Identities, buf.Bytes(), pago.FilePerms); err != nil { return fmt.Errorf("failed to write identities file: %w", err) } @@ -725,12 +823,12 @@ func (cmd *ShowCmd) Run(config *Config) error { } if !cmd.Pick && cmd.Name == "" { - return pago.PrintStoreTree(config.Store) + return tree.PrintStoreTree(config.Store) } name := cmd.Name if cmd.Pick { - picked, err := pago.PickEntry(config.Store, name) + picked, err := input.PickEntry(config.Store, name) if err != nil { return err } @@ -745,6 +843,8 @@ func (cmd *ShowCmd) Run(config *Config) error { } password, err := decryptEntry( + config.AgentExecutable, + config.Mlock, config.Socket, config.Identities, config.Store, @@ -769,7 +869,7 @@ func (cmd *VersionCmd) Run(config *Config) error { printRepr(cmd) } - fmt.Println(version) + fmt.Println(pago.Version) return nil } @@ -783,31 +883,24 @@ func initConfig(cli *CLI) (*Config, error) { store := filepath.Join(cli.Dir, storePath) config := Config{ - Confirm: cli.Confirm, - DataDir: cli.Dir, - Git: cli.Git, - GitEmail: cli.GitEmail, - GitName: cli.GitName, - Home: home, - Identities: filepath.Join(cli.Dir, "identities"), - Recipients: filepath.Join(store, ".age-recipients"), - Socket: cli.Socket, - Store: store, - Verbose: cli.Verbose, + AgentExecutable: cli.AgentExecutable, + Confirm: cli.Confirm, + DataDir: cli.Dir, + Git: cli.Git, + GitEmail: cli.GitEmail, + GitName: cli.GitName, + Home: home, + Identities: filepath.Join(cli.Dir, "identities"), + Mlock: cli.Mlock, + Recipients: filepath.Join(store, ".age-recipients"), + Socket: cli.Socket, + Store: store, + Verbose: cli.Verbose, } return &config, nil } -func printError(format string, value any) { - fmt.Fprintf(os.Stderr, "Error: "+format+"\n", value) -} - -func exitWithError(format string, value any) { - printError(format, value) - os.Exit(1) -} - // Generate a random password where each character matches a regular expression. func generatePassword(pattern string, length int) (string, error) { regexpPattern, err := regexp.Compile(pattern) @@ -839,31 +932,13 @@ func generatePassword(pattern string, length int) (string, error) { return password.String(), nil } -// Map an entry's name to its file path. -func entryFile(passwordStore, name string) (string, error) { - re := regexp.MustCompile(nameInvalidChars) - if re.MatchString(name) { - return "", fmt.Errorf("entry name contains invalid characters matching %s", nameInvalidChars) - } - - file := filepath.Join(passwordStore, name+pago.AgeExt) - - for path := file; path != "/"; path = filepath.Dir(path) { - if path == passwordStore { - return file, nil - } - } - - return "", fmt.Errorf("entry path is out of bounds") -} - func pathExists(path string) bool { _, err := os.Stat(path) return !errors.Is(err, os.ErrNotExist) } func entryExists(passwordStore, name string) bool { - file, err := entryFile(passwordStore, name) + file, err := crypto.EntryFile(passwordStore, name) if err != nil { return false } @@ -871,28 +946,14 @@ func entryExists(passwordStore, name string) bool { return pathExists(file) } -func waitUntilAvailable(path string, maximum time.Duration) error { - start := time.Now() - - for { - if _, err := os.Stat(path); err == nil { - return nil - } - - elapsed := time.Since(start) - if elapsed > maximum { - return fmt.Errorf("reached %v timeout", maximum) - } - - time.Sleep(50 * time.Millisecond) - } -} - func main() { + GitEmail := pago.DefaultGitEmail + GitName := pago.DefaultGitName + globalConfig, err := gitConfig.LoadConfig(gitConfig.GlobalScope) if err == nil { - defaultGitEmail = globalConfig.User.Email - defaultGitName = globalConfig.User.Name + GitEmail = globalConfig.User.Email + GitName = globalConfig.User.Name } var cli CLI @@ -911,33 +972,36 @@ func main() { os.Exit(code) }), kong.Vars{ - "defaultClip": pago.DefaultClip, - "defaultDataDir": defaultDataDir, - "defaultGitEmail": defaultGitEmail, - "defaultGitName": defaultGitName, - "defaultLength": defaultLength, - "defaultPattern": defaultPattern, - "defaultSocket": defaultSocket, - - "clipEnv": clipEnv, - "confirmEnv": confirmEnv, - "dataDirEnv": dataDirEnv, - "gitEnv": gitEnv, - "gitEmailEnv": gitEmailEnv, - "gitNameEnv": gitNameEnv, - "socketEnv": socketEnv, - "timeoutEnv": timeoutEnv, - "lengthEnv": lengthEnv, - "patternEnv": patternEnv, + "DefaultAgent": pago.DefaultAgent, + "DefaultClip": pago.DefaultClip, + "DefaultDataDir": pago.DefaultDataDir, + "DefaultLength": pago.DefaultPasswordLength, + "DefaultPattern": pago.DefaultPasswordPattern, + "DefaultSocket": pago.DefaultSocket, + "GitEmail": GitEmail, + "GitName": GitName, + + "AgentEnv": pago.AgentEnv, + "ClipEnv": pago.ClipEnv, + "ConfirmEnv": pago.ConfirmEnv, + "DataDirEnv": pago.DataDirEnv, + "GitEmailEnv": pago.GitEmailEnv, + "GitEnv": pago.GitEnv, + "GitNameEnv": pago.GitNameEnv, + "MlockEnv": pago.MlockEnv, + "SocketEnv": pago.SocketEnv, + "TimeoutEnv": pago.TimeoutEnv, + "LengthEnv": pago.LengthEnv, + "PatternEnv": pago.PatternEnv, }, ) // Set the default command according to whether the data directory exists. args := os.Args[1:] if len(args) == 0 { - dataDir := os.Getenv(dataDirEnv) + dataDir := os.Getenv(pago.DataDirEnv) if dataDir == "" { - dataDir = defaultDataDir + dataDir = pago.DefaultDataDir } storeDir := filepath.Join(dataDir, storePath) @@ -955,18 +1019,18 @@ func main() { config, err := initConfig(&cli) if err != nil { - exitWithError("%v", err) + pago.ExitWithError("%v", err) } if config.Verbose { printRepr(config) } - err = os.MkdirAll(config.Store, dirPerms) + err = os.MkdirAll(config.Store, pago.DirPerms) if err != nil { - exitWithError("failed to create password store directory: %v", err) + pago.ExitWithError("failed to create password store directory: %v", err) } if err := ctx.Run(config); err != nil { - exitWithError("%v", err) + pago.ExitWithError("%v", err) } } diff --git a/config.go b/config.go index 5855e64..cd8efa0 100644 --- a/config.go +++ b/config.go @@ -5,6 +5,44 @@ package pago +import ( + "path/filepath" + "time" + + "github.com/adrg/xdg" +) + const ( - AgeExt = ".age" + AgeExt = ".age" + AgentSocketPath = "socket" + DirPerms = 0o700 + FilePerms = 0o600 + NameInvalidChars = `[\n]` + Version = "0.10.0" + WaitForSocket = 3 * time.Second + + DefaultAgent = "pago-agent" + DefaultGitEmail = "pago password manager" + DefaultGitName = "pago@localhost" + DefaultPasswordLength = "20" + DefaultPasswordPattern = "[A-Za-z0-9]" + + AgentEnv = "PAGO_AGENT" + ClipEnv = "PAGO_CLIP" + ConfirmEnv = "PAGO_CONFIRM" + DataDirEnv = "PAGO_DIR" + GitEmailEnv = "GIT_AUTHOR_EMAIL" + GitEnv = "PAGO_GIT" + GitNameEnv = "GIT_AUTHOR_NAME" + LengthEnv = "PAGO_LENGTH" + MlockEnv = "PAGO_MLOCK" + PatternEnv = "PAGO_PATTERN" + SocketEnv = "PAGO_SOCK" + TimeoutEnv = "PAGO_TIMEOUT" +) + +var ( + DefaultCacheDir = filepath.Join(xdg.CacheHome, "pago") + DefaultDataDir = filepath.Join(xdg.DataHome, "pago") + DefaultSocket = filepath.Join(DefaultCacheDir, AgentSocketPath) ) diff --git a/cmd/pago/crypto.go b/crypto/crypto.go similarity index 70% rename from cmd/pago/crypto.go rename to crypto/crypto.go index 6a0b647..f023091 100644 --- a/cmd/pago/crypto.go +++ b/crypto/crypto.go @@ -3,7 +3,7 @@ // License: MIT. // See the file LICENSE. -package main +package crypto import ( "bytes" @@ -12,16 +12,18 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "dbohdan.com/pago" + "dbohdan.com/pago/input" "filippo.io/age" "filippo.io/age/armor" ) // Parse the entire text of an age recipients file. -func parseRecipients(contents string) ([]age.Recipient, error) { +func ParseRecipients(contents string) ([]age.Recipient, error) { var recips []age.Recipient for _, line := range strings.Split(contents, "\n") { @@ -42,23 +44,23 @@ func parseRecipients(contents string) ([]age.Recipient, error) { } // Encrypt the password and save it to a file. -func saveEntry(recipients, passwordStore, name, password string) error { +func SaveEntry(recipients, passwordStore, name, password string) error { recipientsData, err := os.ReadFile(recipients) if err != nil { return fmt.Errorf("failed to read recipients file: %v", err) } - recips, err := parseRecipients(string(recipientsData)) + recips, err := ParseRecipients(string(recipientsData)) if err != nil { return err } - dest, err := entryFile(passwordStore, name) + dest, err := pago.EntryFile(passwordStore, name) if err != nil { return err } - err = os.MkdirAll(filepath.Dir(dest), dirPerms) + err = os.MkdirAll(filepath.Dir(dest), pago.DirPerms) if err != nil { return fmt.Errorf("failed to create output path: %v", err) } @@ -91,7 +93,7 @@ func saveEntry(recipients, passwordStore, name, password string) error { } // Returns a reader that can handle both armored and binary age files. -func wrapDecrypt(r io.Reader, identities ...age.Identity) (io.Reader, error) { +func WrapDecrypt(r io.Reader, identities ...age.Identity) (io.Reader, error) { buffer := make([]byte, len(armor.Header)) // Check if the input starts with an armor header. @@ -110,13 +112,13 @@ func wrapDecrypt(r io.Reader, identities ...age.Identity) (io.Reader, error) { return age.Decrypt(r, identities...) } -func decryptIdentities(identitiesPath string) (string, error) { +func DecryptIdentities(identitiesPath string) (string, error) { encryptedData, err := os.ReadFile(identitiesPath) if err != nil { return "", fmt.Errorf("failed to read identities file: %v", err) } - password, err := pago.SecureRead("Enter password to unlock identities: ") + password, err := input.SecureRead("Enter password to unlock identities: ") if err != nil { return "", fmt.Errorf("failed to read password: %v", err) } @@ -127,7 +129,7 @@ func decryptIdentities(identitiesPath string) (string, error) { return "", fmt.Errorf("failed to create password-based identity: %v", err) } - r, err := wrapDecrypt(bytes.NewReader(encryptedData), identity) + r, err := WrapDecrypt(bytes.NewReader(encryptedData), identity) if err != nil { return "", fmt.Errorf("failed to decrypt identities: %v", err) } @@ -140,8 +142,8 @@ func decryptIdentities(identitiesPath string) (string, error) { return string(decrypted), nil } -func decryptEntry(agentSocket, identities, passwordStore, name string) (string, error) { - file, err := entryFile(passwordStore, name) +func DecryptEntry(identities, passwordStore, name string) (string, error) { + file, err := pago.EntryFile(passwordStore, name) if err != nil { return "", err } @@ -151,32 +153,7 @@ func decryptEntry(agentSocket, identities, passwordStore, name string) (string, return "", fmt.Errorf("failed to read password file: %v", err) } - // If an agent socket is configured, try to use the agent. - if agentSocket != "" { - if err := pingAgent(agentSocket); err != nil { - // Ping failed. - // Attempt to start the agent. - identitiesText, err := decryptIdentities(identities) - if err != nil { - return "", err - } - - if err := startAgentProcess(agentSocket, identitiesText); err != nil { - return "", fmt.Errorf("failed to start agent: %v", err) - } - } - - password, err := decryptWithAgent(agentSocket, encryptedData) - if err != nil { - return "", err - } - - return password, nil - } - - // When no agent socket is configured, decrypt directly. - // Decrypt the password-protected identities file first. - identitiesText, err := decryptIdentities(identities) + identitiesText, err := DecryptIdentities(identities) if err != nil { return "", err } @@ -186,7 +163,7 @@ func decryptEntry(agentSocket, identities, passwordStore, name string) (string, return "", fmt.Errorf("failed to parse identities: %v", err) } - r, err := wrapDecrypt(bytes.NewReader(encryptedData), ids...) + r, err := WrapDecrypt(bytes.NewReader(encryptedData), ids...) if err != nil { return "", fmt.Errorf("failed to decrypt: %v", err) } @@ -198,3 +175,20 @@ func decryptEntry(agentSocket, identities, passwordStore, name string) (string, return string(password), nil } + +func EntryFile(passwordStore, name string) (string, error) { + re := regexp.MustCompile(pago.NameInvalidChars) + if re.MatchString(name) { + return "", fmt.Errorf("entry name contains invalid characters matching %s", pago.NameInvalidChars) + } + + file := filepath.Join(passwordStore, name+pago.AgeExt) + + for path := file; path != "/"; path = filepath.Dir(path) { + if path == passwordStore { + return file, nil + } + } + + return "", fmt.Errorf("entry path is out of bounds") +} diff --git a/editor.go b/editor/editor.go similarity index 99% rename from editor.go rename to editor/editor.go index 9ae3e0e..b3b00c8 100644 --- a/editor.go +++ b/editor/editor.go @@ -3,7 +3,7 @@ // License: MIT. // See the file LICENSE. -package pago +package editor import ( "fmt" diff --git a/git.go b/git/git.go similarity index 82% rename from git.go rename to git/git.go index 91fcbd9..3d99125 100644 --- a/git.go +++ b/git/git.go @@ -3,19 +3,19 @@ // License: MIT. // See the file LICENSE. -package pago +package git import ( "fmt" "path/filepath" "time" - "github.com/go-git/go-git/v5" + gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" ) -func InitGitRepo(repoDir string) error { - _, err := git.PlainInit(repoDir, false) +func InitRepo(repoDir string) error { + _, err := gogit.PlainInit(repoDir, false) if err != nil { return fmt.Errorf("failed to initialize Git repository: %v", err) } @@ -24,7 +24,7 @@ func InitGitRepo(repoDir string) error { } func Commit(repoDir, authorName, authorEmail, message string, add []string) error { - repo, err := git.PlainOpen(repoDir) + repo, err := gogit.PlainOpen(repoDir) if err != nil { return fmt.Errorf("failed to open Git repository: %v", err) } @@ -46,7 +46,7 @@ func Commit(repoDir, authorName, authorEmail, message string, add []string) erro } } - _, err = w.Commit(message, &git.CommitOptions{ + _, err = w.Commit(message, &gogit.CommitOptions{ Author: &object.Signature{ Name: authorName, Email: authorEmail, diff --git a/user_input.go b/input/input.go similarity index 96% rename from user_input.go rename to input/input.go index a331990..48d954e 100644 --- a/user_input.go +++ b/input/input.go @@ -3,7 +3,7 @@ // License: MIT. // See the file LICENSE. -package pago +package input import ( "bufio" @@ -13,6 +13,8 @@ import ( "strings" "syscall" + "dbohdan.com/pago" + "github.com/ktr0731/go-fuzzyfinder" "golang.org/x/term" ) @@ -20,7 +22,7 @@ import ( // Pick an entry using a fuzzy finder. func PickEntry(store string, query string) (string, error) { // Create a list of all passwords. - list, err := ListFiles(store, EntryFilter(store, nil)) + list, err := pago.ListFiles(store, pago.EntryFilter(store, nil)) if err != nil { return "", fmt.Errorf("failed to list passwords: %v", err) } diff --git a/cmd/pago/main_test.go b/test/integration_test.go similarity index 98% rename from cmd/pago/main_test.go rename to test/integration_test.go index ac0224e..6a78109 100644 --- a/cmd/pago/main_test.go +++ b/test/integration_test.go @@ -3,7 +3,7 @@ // License: MIT. // See the file LICENSE. -package main +package test import ( "bytes" @@ -16,7 +16,7 @@ import ( "strings" "testing" - "dbohdan.com/pago" + "dbohdan.com/pago/tree" "filippo.io/age" "filippo.io/age/armor" @@ -24,8 +24,9 @@ import ( ) const ( - commandPago = "./pago" - password = "test" + commandPago = "../cmd/pago/pago" + commandPagoAgent = "../cmd/pago-agent/pago-agent" + password = "test" ) func runCommandEnv(env []string, args ...string) (string, string, error) { @@ -122,7 +123,7 @@ func TestBadUsage(t *testing.T) { func TestInit(t *testing.T) { tree, err := withPagoDir(func(dataDir string) (string, error) { - return pago.DirTree(dataDir, func(name string, info os.FileInfo) (bool, string) { + return tree.DirTree(dataDir, func(name string, info os.FileInfo) (bool, string) { return true, name }) }) @@ -562,9 +563,13 @@ func TestAgentStartPingStop(t *testing.T) { defer c.Close() socketPath := filepath.Join(dataDir, "agent.sock") + var buf bytes.Buffer + cmd := exec.Command( commandPago, + "--agent", commandPagoAgent, "--dir", dataDir, + "--no-mlock", "--socket", socketPath, "agent", "start", ) diff --git a/tree.go b/tree/tree.go similarity index 53% rename from tree.go rename to tree/tree.go index fda12e1..7bd5590 100644 --- a/tree.go +++ b/tree/tree.go @@ -3,15 +3,16 @@ // License: MIT. // See the file LICENSE. -package pago +package tree import ( "fmt" "os" "path/filepath" - "regexp" "strings" + "dbohdan.com/pago" + "github.com/xlab/treeprint" ) @@ -68,7 +69,7 @@ func PrintStoreTree(store string) error { return false, "" } - displayName := strings.TrimSuffix(info.Name(), AgeExt) + displayName := strings.TrimSuffix(info.Name(), pago.AgeExt) if info.IsDir() { displayName += "/" } @@ -82,52 +83,3 @@ func PrintStoreTree(store string) error { fmt.Print(tree) return nil } - -func ListFiles(root string, transform func(name string, info os.FileInfo) (bool, string)) ([]string, error) { - list := []string{} - - err := filepath.Walk(root, func(name string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - name, err = filepath.Abs(name) - if err != nil { - return err - } - - keep, displayName := transform(name, info) - if !keep { - return nil - } - - list = append(list, displayName) - - return nil - }) - if err != nil { - return []string{}, err - } - - return list, nil -} - -// Return a function that filters entries by a filename pattern. -func EntryFilter(root string, pattern *regexp.Regexp) func(name string, info os.FileInfo) (bool, string) { - return func(name string, info os.FileInfo) (bool, string) { - if info.IsDir() || !strings.HasSuffix(name, AgeExt) { - return false, "" - } - - displayName := name - displayName = strings.TrimPrefix(displayName, root) - displayName = strings.TrimPrefix(displayName, "/") - displayName = strings.TrimSuffix(displayName, AgeExt) - - if pattern != nil && !pattern.MatchString(displayName) { - return false, "" - } - - return true, displayName - } -} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..1ef8af3 --- /dev/null +++ b/utils.go @@ -0,0 +1,108 @@ +// pago - a command-line password manager. +// +// License: MIT. +// See the file LICENSE. + +package pago + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Map an entry's name to its file path. +func EntryFile(passwordStore, name string) (string, error) { + re := regexp.MustCompile(NameInvalidChars) + if re.MatchString(name) { + return "", fmt.Errorf("entry name contains invalid characters matching %s", NameInvalidChars) + } + + file := filepath.Join(passwordStore, name+AgeExt) + + for path := file; path != "/"; path = filepath.Dir(path) { + if path == passwordStore { + return file, nil + } + } + + return "", fmt.Errorf("entry path is out of bounds") +} + +func WaitUntilAvailable(path string, maximum time.Duration) error { + start := time.Now() + + for { + if _, err := os.Stat(path); err == nil { + return nil + } + + elapsed := time.Since(start) + if elapsed > maximum { + return fmt.Errorf("reached %v timeout", maximum) + } + + time.Sleep(50 * time.Millisecond) + } +} + +func PrintError(format string, value any) { + fmt.Fprintf(os.Stderr, "Error: "+format+"\n", value) +} + +func ExitWithError(format string, value any) { + PrintError(format, value) + os.Exit(1) +} + +func ListFiles(root string, transform func(name string, info os.FileInfo) (bool, string)) ([]string, error) { + list := []string{} + + err := filepath.Walk(root, func(name string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + name, err = filepath.Abs(name) + if err != nil { + return err + } + + keep, displayName := transform(name, info) + if !keep { + return nil + } + + list = append(list, displayName) + + return nil + }) + if err != nil { + return []string{}, err + } + + return list, nil +} + +// Return a function that filters entries by a filename pattern. +func EntryFilter(root string, pattern *regexp.Regexp) func(name string, info os.FileInfo) (bool, string) { + return func(name string, info os.FileInfo) (bool, string) { + if info.IsDir() || !strings.HasSuffix(name, AgeExt) { + return false, "" + } + + displayName := name + displayName = strings.TrimPrefix(displayName, root) + displayName = strings.TrimPrefix(displayName, "/") + displayName = strings.TrimSuffix(displayName, AgeExt) + + if pattern != nil && !pattern.MatchString(displayName) { + return false, "" + } + + return true, displayName + } +}