mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-12-12 11:15:00 +02:00
1a1f042f49
The 8.2 release of OpenSSH added support for FIDO/U2F hardware authenticators, which manifests in being able to create new types of SSH key, named `ecdsa-sk` nad `ed25519-sk`. This is relevant to lazygit, as those SSH keys can be used to authorise git operations over SSH, as well as signing git commits. Actual code changes are required for correct support, as the authentication process for these types of keys is different than the process for types supported previously. When an operation requiring credentials is initialised with a U2F authenticator-backed key, the first prompt is: Enter PIN for ${key_type} key ${path_to_key}: at which point the user is supposed to enter a numeric (and secret) PIN, specific to the particular FIDO/U2F authenticator using which the SSH keypair was generated. Upon entering the correct key, the user is supposed to physically interact with the authenticator to confirm presence. Sometimes this is accompanied by the following text prompt: Confirm user presence for key ${key_type} ${key_fingerprint} This second prompt does not always occur and it is presumed that the user will know to perform this step even if not prompted specifically. At this stage some authenticator devices may also begin to blink a LED to indicate that they're waiting for input. To facilitate lazygit's interoperability with these types of keys, add support for the first PIN prompt, which allows "fetch", "pull", and "push" git operations to complete.
352 lines
9.4 KiB
Go
352 lines
9.4 KiB
Go
package oscommands
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-errors/errors"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type ICmdObjRunner interface {
|
|
Run(cmdObj ICmdObj) error
|
|
RunWithOutput(cmdObj ICmdObj) (string, error)
|
|
RunWithOutputs(cmdObj ICmdObj) (string, string, error)
|
|
RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error
|
|
}
|
|
|
|
type CredentialType int
|
|
|
|
const (
|
|
Password CredentialType = iota
|
|
Username
|
|
Passphrase
|
|
PIN
|
|
)
|
|
|
|
type cmdObjRunner struct {
|
|
log *logrus.Entry
|
|
guiIO *guiIO
|
|
}
|
|
|
|
var _ ICmdObjRunner = &cmdObjRunner{}
|
|
|
|
func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
|
|
if cmdObj.Mutex() != nil {
|
|
cmdObj.Mutex().Lock()
|
|
defer cmdObj.Mutex().Unlock()
|
|
}
|
|
|
|
if cmdObj.GetCredentialStrategy() != NONE {
|
|
return self.runWithCredentialHandling(cmdObj)
|
|
}
|
|
|
|
if cmdObj.ShouldStreamOutput() {
|
|
return self.runAndStream(cmdObj)
|
|
}
|
|
|
|
_, err := self.RunWithOutputAux(cmdObj)
|
|
return err
|
|
}
|
|
|
|
func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
|
|
if cmdObj.Mutex() != nil {
|
|
cmdObj.Mutex().Lock()
|
|
defer cmdObj.Mutex().Unlock()
|
|
}
|
|
|
|
if cmdObj.GetCredentialStrategy() != NONE {
|
|
err := self.runWithCredentialHandling(cmdObj)
|
|
// for now we're not capturing output, just because it would take a little more
|
|
// effort and there's currently no use case for it. Some commands call RunWithOutput
|
|
// but ignore the output, hence why we've got this check here.
|
|
return "", err
|
|
}
|
|
|
|
if cmdObj.ShouldStreamOutput() {
|
|
err := self.runAndStream(cmdObj)
|
|
// for now we're not capturing output, just because it would take a little more
|
|
// effort and there's currently no use case for it. Some commands call RunWithOutput
|
|
// but ignore the output, hence why we've got this check here.
|
|
return "", err
|
|
}
|
|
|
|
return self.RunWithOutputAux(cmdObj)
|
|
}
|
|
|
|
func (self *cmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error) {
|
|
if cmdObj.Mutex() != nil {
|
|
cmdObj.Mutex().Lock()
|
|
defer cmdObj.Mutex().Unlock()
|
|
}
|
|
|
|
if cmdObj.GetCredentialStrategy() != NONE {
|
|
err := self.runWithCredentialHandling(cmdObj)
|
|
// for now we're not capturing output, just because it would take a little more
|
|
// effort and there's currently no use case for it. Some commands call RunWithOutputs
|
|
// but ignore the output, hence why we've got this check here.
|
|
return "", "", err
|
|
}
|
|
|
|
if cmdObj.ShouldStreamOutput() {
|
|
err := self.runAndStream(cmdObj)
|
|
// for now we're not capturing output, just because it would take a little more
|
|
// effort and there's currently no use case for it. Some commands call RunWithOutputs
|
|
// but ignore the output, hence why we've got this check here.
|
|
return "", "", err
|
|
}
|
|
|
|
return self.RunWithOutputsAux(cmdObj)
|
|
}
|
|
|
|
func (self *cmdObjRunner) RunWithOutputAux(cmdObj ICmdObj) (string, error) {
|
|
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
|
|
|
|
if cmdObj.ShouldLog() {
|
|
self.logCmdObj(cmdObj)
|
|
}
|
|
|
|
output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput())
|
|
if err != nil {
|
|
self.log.WithField("command", cmdObj.ToString()).Error(output)
|
|
}
|
|
return output, err
|
|
}
|
|
|
|
func (self *cmdObjRunner) RunWithOutputsAux(cmdObj ICmdObj) (string, string, error) {
|
|
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
|
|
|
|
if cmdObj.ShouldLog() {
|
|
self.logCmdObj(cmdObj)
|
|
}
|
|
|
|
var outBuffer, errBuffer bytes.Buffer
|
|
cmd := cmdObj.GetCmd()
|
|
cmd.Stdout = &outBuffer
|
|
cmd.Stderr = &errBuffer
|
|
err := cmd.Run()
|
|
|
|
stdout := outBuffer.String()
|
|
stderr, err := sanitisedCommandOutput(errBuffer.Bytes(), err)
|
|
if err != nil {
|
|
self.log.WithField("command", cmdObj.ToString()).Error(stderr)
|
|
}
|
|
|
|
return stdout, stderr, err
|
|
}
|
|
|
|
func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
|
|
if cmdObj.Mutex() != nil {
|
|
cmdObj.Mutex().Lock()
|
|
defer cmdObj.Mutex().Unlock()
|
|
}
|
|
|
|
if cmdObj.GetCredentialStrategy() != NONE {
|
|
return errors.New("cannot call RunAndProcessLines with credential strategy. If you're seeing this then a contributor to Lazygit has accidentally called this method! Please raise an issue")
|
|
}
|
|
|
|
if cmdObj.ShouldLog() {
|
|
self.logCmdObj(cmdObj)
|
|
}
|
|
|
|
cmd := cmdObj.GetCmd()
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stdoutPipe)
|
|
scanner.Split(bufio.ScanLines)
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
stop, err := onLine(line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if stop {
|
|
_ = Kill(cmd)
|
|
break
|
|
}
|
|
}
|
|
|
|
_ = cmd.Wait()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Whenever we're asked for a password we just enter a newline, which will
|
|
// eventually cause the command to fail.
|
|
var failPromptFn = func(CredentialType) string { return "\n" }
|
|
|
|
func (self *cmdObjRunner) runWithCredentialHandling(cmdObj ICmdObj) error {
|
|
var promptFn func(CredentialType) string
|
|
|
|
switch cmdObj.GetCredentialStrategy() {
|
|
case PROMPT:
|
|
promptFn = self.guiIO.promptForCredentialFn
|
|
case FAIL:
|
|
promptFn = failPromptFn
|
|
case NONE:
|
|
// we should never land here
|
|
return errors.New("runWithCredentialHandling called but cmdObj does not have a credential strategy")
|
|
}
|
|
|
|
return self.runAndDetectCredentialRequest(cmdObj, promptFn)
|
|
}
|
|
|
|
func (self *cmdObjRunner) logCmdObj(cmdObj ICmdObj) {
|
|
self.guiIO.logCommandFn(cmdObj.ToString(), true)
|
|
}
|
|
|
|
func sanitisedCommandOutput(output []byte, err error) (string, error) {
|
|
outputString := string(output)
|
|
if err != nil {
|
|
// errors like 'exit status 1' are not very useful so we'll create an error
|
|
// from the combined output
|
|
if outputString == "" {
|
|
return "", utils.WrapError(err)
|
|
}
|
|
return outputString, errors.New(outputString)
|
|
}
|
|
return outputString, nil
|
|
}
|
|
|
|
type cmdHandler struct {
|
|
stdoutPipe io.Reader
|
|
stdinPipe io.Writer
|
|
close func() error
|
|
}
|
|
|
|
func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error {
|
|
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
|
go func() {
|
|
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
|
|
}()
|
|
})
|
|
}
|
|
|
|
// runAndDetectCredentialRequest detect a username / password / passphrase question in a command
|
|
// promptUserForCredential is a function that gets executed when this function detect you need to fillin a password or passphrase
|
|
// The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back
|
|
func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
|
cmdObj ICmdObj,
|
|
promptUserForCredential func(CredentialType) string,
|
|
) error {
|
|
// setting the output to english so we can parse it for a username/password request
|
|
cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8")
|
|
|
|
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
|
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
|
|
|
|
go utils.Safe(func() {
|
|
self.processOutput(tr, handler.stdinPipe, promptUserForCredential)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (self *cmdObjRunner) runAndStreamAux(
|
|
cmdObj ICmdObj,
|
|
onRun func(*cmdHandler, io.Writer),
|
|
) error {
|
|
// if we're streaming this we don't want any fancy terminal stuff
|
|
cmdObj.AddEnvVars("TERM=dumb")
|
|
|
|
cmdWriter := self.guiIO.newCmdWriterFn()
|
|
|
|
if cmdObj.ShouldLog() {
|
|
self.logCmdObj(cmdObj)
|
|
}
|
|
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
|
|
cmd := cmdObj.GetCmd()
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = io.MultiWriter(cmdWriter, &stderr)
|
|
|
|
handler, err := self.getCmdHandler(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
handler.stdoutPipe = io.TeeReader(handler.stdoutPipe, &stdout)
|
|
|
|
defer func() {
|
|
if closeErr := handler.close(); closeErr != nil {
|
|
self.log.Error(closeErr)
|
|
}
|
|
}()
|
|
|
|
onRun(handler, cmdWriter)
|
|
|
|
err = cmd.Wait()
|
|
if err != nil {
|
|
errStr := stderr.String()
|
|
if errStr != "" {
|
|
return errors.New(errStr)
|
|
}
|
|
|
|
if cmdObj.ShouldIgnoreEmptyError() {
|
|
return nil
|
|
}
|
|
return errors.New(stdout.String())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *cmdObjRunner) processOutput(reader io.Reader, writer io.Writer, promptUserForCredential func(CredentialType) string) {
|
|
checkForCredentialRequest := self.getCheckForCredentialRequestFunc()
|
|
|
|
scanner := bufio.NewScanner(reader)
|
|
scanner.Split(bufio.ScanBytes)
|
|
for scanner.Scan() {
|
|
newBytes := scanner.Bytes()
|
|
askFor, ok := checkForCredentialRequest(newBytes)
|
|
if ok {
|
|
toInput := promptUserForCredential(askFor)
|
|
// If the return data is empty we don't write anything to stdin
|
|
if toInput != "" {
|
|
_, _ = writer.Write([]byte(toInput))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// having a function that returns a function because we need to maintain some state inbetween calls hence the closure
|
|
func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (CredentialType, bool) {
|
|
var ttyText strings.Builder
|
|
// this function takes each word of output from the command and builds up a string to see if we're being asked for a password
|
|
return func(newBytes []byte) (CredentialType, bool) {
|
|
_, err := ttyText.Write(newBytes)
|
|
if err != nil {
|
|
self.log.Error(err)
|
|
}
|
|
|
|
prompts := map[string]CredentialType{
|
|
`Password:`: Password,
|
|
`.+'s password:`: Password,
|
|
`Password\s*for\s*'.+':`: Password,
|
|
`Username\s*for\s*'.+':`: Username,
|
|
`Enter\s*passphrase\s*for\s*key\s*'.+':`: Passphrase,
|
|
`Enter\s*PIN\s*for\s*.+\s*key\s*.+:`: PIN,
|
|
}
|
|
|
|
for pattern, askFor := range prompts {
|
|
if match, _ := regexp.MatchString(pattern, ttyText.String()); match {
|
|
ttyText.Reset()
|
|
return askFor, true
|
|
}
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
}
|