From 22187f61be409e0aa119f538b3000df4099a8325 Mon Sep 17 00:00:00 2001 From: "D. Bohdan" Date: Thu, 20 Feb 2025 15:34:45 +0000 Subject: [PATCH] refactor: split out packages and agent executable This allows for a smaller agent executable that uses less (locked) memory. Stop locking memory in the main pago executable when contacting the agent. It confuses security expectations because operations without an agent don't do it. v0.10.0 --- .gitignore | 1 + README.md | 7 +- Taskfile.yml | 25 +- {cmd/pago => agent}/agent.go | 140 ++---- cmd/pago-agent/main.go | 84 ++++ mlockall.go => cmd/pago-agent/mlockall.go | 4 +- cmd/pago/main.go | 412 ++++++++++-------- config.go | 40 +- {cmd/pago => crypto}/crypto.go | 72 ++- editor.go => editor/editor.go | 2 +- git.go => git/git.go | 12 +- user_input.go => input/input.go | 6 +- .../main_test.go => test/integration_test.go | 15 +- tree.go => tree/tree.go | 56 +-- utils.go | 108 +++++ 15 files changed, 584 insertions(+), 400 deletions(-) rename {cmd/pago => agent}/agent.go (56%) create mode 100644 cmd/pago-agent/main.go rename mlockall.go => cmd/pago-agent/mlockall.go (90%) rename {cmd/pago => crypto}/crypto.go (70%) rename editor.go => editor/editor.go (99%) rename git.go => git/git.go (82%) rename user_input.go => input/input.go (96%) rename cmd/pago/main_test.go => test/integration_test.go (98%) rename tree.go => tree/tree.go (53%) create mode 100644 utils.go 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 + } +}