Repository: libgit2/git2go
Pull request #810 (2021-09-05 23:30:21)

Add support for managed HTTP/S transports
This change uses the newly-exposed Transport interface to use Go's
implementation of http.Client instead of httpclient via libgit2.


Patch:
diff --git a/credentials.go b/credentials.go
index 843c6b2..273de2f 100644
--- a/credentials.go
+++ b/credentials.go
@@ -11,6 +11,7 @@ void _go_git_populate_credential_ssh_custom(git_credential_ssh_custom *cred);
 import "C"
 import (
 	"crypto/rand"
+	"errors"
 	"fmt"
 	"runtime"
 	"strings"
@@ -106,6 +107,19 @@ func (o *Credential) Free() {
 	o.ptr = nil
 }
 
+// GetUserpassPlaintext returns the plaintext username/password combination stored in the Cred.
+func (o *Credential) GetUserpassPlaintext() (username, password string, err error) {
+	if o.Type() != CredentialTypeUserpassPlaintext {
+		err = errors.New("credential is not userpass plaintext")
+		return
+	}
+
+	plaintextCredPtr := (*C.git_cred_userpass_plaintext)(unsafe.Pointer(o.ptr))
+	username = C.GoString(plaintextCredPtr.username)
+	password = C.GoString(plaintextCredPtr.password)
+	return
+}
+
 func NewCredentialUsername(username string) (*Credential, error) {
 	runtime.LockOSThread()
 	defer runtime.UnlockOSThread()
diff --git a/git.go b/git.go
index adf07ae..9ad1ffc 100644
--- a/git.go
+++ b/git.go
@@ -139,22 +139,28 @@ func initLibGit2() {
 	remotePointers = newRemotePointerList()
 
 	C.git_libgit2_init()
+	features := Features()
 
 	// Due to the multithreaded nature of Go and its interaction with
 	// calling C functions, we cannot work with a library that was not built
 	// with multi-threading support. The most likely outcome is a segfault
 	// or panic at an incomprehensible time, so let's make it easy by
 	// panicking right here.
-	if Features()&FeatureThreads == 0 {
+	if features&FeatureThreads == 0 {
 		panic("libgit2 was not built with threading support")
 	}
 
-	// This is not something we should be doing, as we may be
-	// stomping all over someone else's setup. The user should do
-	// this themselves or use some binding/wrapper which does it
-	// in such a way that they can be sure they're the only ones
-	// setting it up.
-	C.git_openssl_set_locking()
+	if features&FeatureHTTPS == 0 {
+		if err := registerManagedHTTP(); err != nil {
+			panic(err)
+		}
+	} else {
+		// This is not something we should be doing, as we may be stomping all over
+		// someone else's setup. The user should do this themselves or use some
+		// binding/wrapper which does it in such a way that they can be sure
+		// they're the only ones setting it up.
+		C.git_openssl_set_locking()
+	}
 }
 
 // Shutdown frees all the resources acquired by libgit2. Make sure no
diff --git a/http.go b/http.go
new file mode 100644
index 0000000..0777c56
--- /dev/null
+++ b/http.go
@@ -0,0 +1,241 @@
+package git
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"sync"
+)
+
+// RegisterManagedHTTPTransport registers a Go-native implementation of an
+// HTTP/S transport that doesn't rely on any system libraries (e.g.
+// libopenssl/libmbedtls).
+//
+// If Shutdown or ReInit are called, make sure that the smart transports are
+// freed before it.
+func RegisterManagedHTTPTransport(protocol string) (*RegisteredSmartTransport, error) {
+	return NewRegisteredSmartTransport(protocol, true, httpSmartSubtransportFactory)
+}
+
+func registerManagedHTTP() error {
+	globalRegisteredSmartTransports.Lock()
+	defer globalRegisteredSmartTransports.Unlock()
+
+	for _, protocol := range []string{"http", "https"} {
+		if _, ok := globalRegisteredSmartTransports.transports[protocol]; ok {
+			continue
+		}
+		managed, err := newRegisteredSmartTransport(protocol, true, httpSmartSubtransportFactory, true)
+		if err != nil {
+			return fmt.Errorf("failed to register transport for %q: %v", protocol, err)
+		}
+		globalRegisteredSmartTransports.transports[protocol] = managed
+	}
+	return nil
+}
+
+func httpSmartSubtransportFactory(remote *Remote, transport *Transport) (SmartSubtransport, error) {
+	var proxyFn func(*http.Request) (*url.URL, error)
+	proxyOpts, err := transport.SmartProxyOptions()
+	if err != nil {
+		return nil, err
+	}
+	switch proxyOpts.Type {
+	case ProxyTypeNone:
+		proxyFn = nil
+	case ProxyTypeAuto:
+		proxyFn = http.ProxyFromEnvironment
+	case ProxyTypeSpecified:
+		parsedUrl, err := url.Parse(proxyOpts.Url)
+		if err != nil {
+			return nil, err
+		}
+
+		proxyFn = http.ProxyURL(parsedUrl)
+	}
+
+	return &httpSmartSubtransport{
+		transport: transport,
+		client: &http.Client{
+			Transport: &http.Transport{
+				Proxy: proxyFn,
+			},
+		},
+	}, nil
+}
+
+type httpSmartSubtransport struct {
+	transport *Transport
+	client    *http.Client
+}
+
+func (t *httpSmartSubtransport) Action(url string, action SmartServiceAction) (SmartSubtransportStream, error) {
+	var req *http.Request
+	var err error
+	switch action {
+	case SmartServiceActionUploadpackLs:
+		req, err = http.NewRequest("GET", url+"/info/refs?service=git-upload-pack", nil)
+
+	case SmartServiceActionUploadpack:
+		req, err = http.NewRequest("POST", url+"/git-upload-pack", nil)
+		if err != nil {
+			break
+		}
+		req.Header.Set("Content-Type", "application/x-git-upload-pack-request")
+
+	case SmartServiceActionReceivepackLs:
+		req, err = http.NewRequest("GET", url+"/info/refs?service=git-receive-pack", nil)
+
+	case SmartServiceActionReceivepack:
+		req, err = http.NewRequest("POST", url+"/info/refs?service=git-upload-pack", nil)
+		if err != nil {
+			break
+		}
+		req.Header.Set("Content-Type", "application/x-git-receive-pack-request")
+
+	default:
+		err = errors.New("unknown action")
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("User-Agent", "git/2.0 (git2go)")
+
+	stream := newManagedHttpStream(t, req)
+	if req.Method == "POST" {
+		stream.recvReply.Add(1)
+		stream.sendRequestBackground()
+	}
+
+	return stream, nil
+}
+
+func (t *httpSmartSubtransport) Close() error {
+	return nil
+}
+
+func (t *httpSmartSubtransport) Free() {
+	t.client = nil
+}
+
+type httpSmartSubtransportStream struct {
+	owner       *httpSmartSubtransport
+	req         *http.Request
+	resp        *http.Response
+	reader      *io.PipeReader
+	writer      *io.PipeWriter
+	sentRequest bool
+	recvReply   sync.WaitGroup
+	httpError   error
+}
+
+func newManagedHttpStream(owner *httpSmartSubtransport, req *http.Request) *httpSmartSubtransportStream {
+	r, w := io.Pipe()
+	return &httpSmartSubtransportStream{
+		owner:  owner,
+		req:    req,
+		reader: r,
+		writer: w,
+	}
+}
+
+func (self *httpSmartSubtransportStream) Read(buf []byte) (int, error) {
+	if !self.sentRequest {
+		self.recvReply.Add(1)
+		if err := self.sendRequest(); err != nil {
+			return 0, err
+		}
+	}
+
+	if err := self.writer.Close(); err != nil {
+		return 0, err
+	}
+
+	self.recvReply.Wait()
+
+	if self.httpError != nil {
+		return 0, self.httpError
+	}
+
+	return self.resp.Body.Read(buf)
+}
+
+func (self *httpSmartSubtransportStream) Write(buf []byte) (int, error) {
+	if self.httpError != nil {
+		return 0, self.httpError
+	}
+	return self.writer.Write(buf)
+}
+
+func (self *httpSmartSubtransportStream) Free() {
+	if self.resp != nil {
+		self.resp.Body.Close()
+	}
+}
+
+func (self *httpSmartSubtransportStream) sendRequestBackground() {
+	go func() {
+		self.httpError = self.sendRequest()
+	}()
+	self.sentRequest = true
+}
+
+func (self *httpSmartSubtransportStream) sendRequest() error {
+	defer self.recvReply.Done()
+	self.resp = nil
+
+	var resp *http.Response
+	var err error
+	var userName string
+	var password string
+	for {
+		req := &http.Request{
+			Method: self.req.Method,
+			URL:    self.req.URL,
+			Header: self.req.Header,
+		}
+		if req.Method == "POST" {
+			req.Body = self.reader
+			req.ContentLength = -1
+		}
+
+		req.SetBasicAuth(userName, password)
+		resp, err = http.DefaultClient.Do(req)
+		if err != nil {
+			return err
+		}
+
+		if resp.StatusCode == http.StatusOK {
+			break
+		}
+
+		if resp.StatusCode == http.StatusUnauthorized {
+			resp.Body.Close()
+
+			cred, err := self.owner.transport.SmartCredentials("", CredentialTypeUserpassPlaintext)
+			if err != nil {
+				return err
+			}
+			defer cred.Free()
+
+			userName, password, err = cred.GetUserpassPlaintext()
+			if err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		// Any other error we treat as a hard error and punt back to the caller
+		resp.Body.Close()
+		return fmt.Errorf("Unhandled HTTP error %s", resp.Status)
+	}
+
+	self.sentRequest = true
+	self.resp = resp
+	return nil
+}
diff --git a/remote.go b/remote.go
index fb70f55..3a435d1 100644
--- a/remote.go
+++ b/remote.go
@@ -168,6 +168,13 @@ type ProxyOptions struct {
 	Url string
 }
 
+func proxyOptionsFromC(copts *C.git_proxy_options) *ProxyOptions {
+	return &ProxyOptions{
+		Type: ProxyType(copts._type),
+		Url:  C.GoString(copts.url),
+	}
+}
+
 type Remote struct {
 	doNotCompare
 	ptr       *C.git_remote
diff --git a/script/build-libgit2.sh b/script/build-libgit2.sh
index 271a823..90a225c 100755
--- a/script/build-libgit2.sh
+++ b/script/build-libgit2.sh
@@ -67,6 +67,7 @@ cmake -DTHREADSAFE=ON \
       -DBUILD_CLAR=OFF \
       -DBUILD_SHARED_LIBS"=${BUILD_SHARED_LIBS}" \
       -DREGEX_BACKEND=builtin \
+      -DUSE_HTTPS=OFF \
       -DCMAKE_C_FLAGS=-fPIC \
       -DCMAKE_BUILD_TYPE="RelWithDebInfo" \
       -DCMAKE_INSTALL_PREFIX="${BUILD_INSTALL_PREFIX}" \
diff --git a/transport.go b/transport.go
index 94c9ffa..cf43acc 100644
--- a/transport.go
+++ b/transport.go
@@ -1,6 +1,8 @@
 package git
 
 /*
+#include <string.h>
+
 #include <git2.h>
 #include <git2/sys/transport.h>
 
@@ -83,6 +85,19 @@ type Transport struct {
 	ptr *C.git_transport
 }
 
+// SmartProxyOptions gets a copy of the proxy options for this transport.
+func (t *Transport) SmartProxyOptions() (*ProxyOptions, error) {
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	var cpopts C.git_proxy_options
+	if ret := C.git_transport_smart_proxy_options(&cpopts, t.ptr); ret < 0 {
+		return nil, MakeGitError(ret)
+	}
+
+	return proxyOptionsFromC(&cpopts), nil
+}
+
 // SmartCredentials calls the credentials callback for this transport.
 func (t *Transport) SmartCredentials(user string, methods CredentialType) (*Credential, error) {
 	cred := newCredential()
@@ -104,6 +119,53 @@ func (t *Transport) SmartCredentials(user string, methods CredentialType) (*Cred
 	return cred, nil
 }
 
+// SmartCertificateCheck calls the certificate check for this transport.
+func (t *Transport) SmartCertificateCheck(cert *Certificate, valid bool, hostname string) error {
+	var ccert *C.git_cert
+	switch cert.Kind {
+	case CertificateHostkey:
+		chostkeyCert := C.git_cert_hostkey{
+			parent: C.git_cert{
+				cert_type: C.GIT_CERT_HOSTKEY_LIBSSH2,
+			},
+			_type: C.git_cert_ssh_t(cert.Kind),
+		}
+		C.memcpy(unsafe.Pointer(&chostkeyCert.hash_md5[0]), unsafe.Pointer(&cert.Hostkey.HashMD5[0]), C.size_t(len(cert.Hostkey.HashMD5)))
+		C.memcpy(unsafe.Pointer(&chostkeyCert.hash_sha1[0]), unsafe.Pointer(&cert.Hostkey.HashSHA1[0]), C.size_t(len(cert.Hostkey.HashSHA1)))
+		C.memcpy(unsafe.Pointer(&chostkeyCert.hash_sha256[0]), unsafe.Pointer(&cert.Hostkey.HashSHA256[0]), C.size_t(len(cert.Hostkey.HashSHA256)))
+		ccert = (*C.git_cert)(unsafe.Pointer(&chostkeyCert))
+
+	case CertificateX509:
+		cx509Cert := C.git_cert_x509{
+			parent: C.git_cert{
+				cert_type: C.GIT_CERT_X509,
+			},
+			len:  C.size_t(len(cert.X509.Raw)),
+			data: C.CBytes(cert.X509.Raw),
+		}
+		defer C.free(cx509Cert.data)
+		ccert = (*C.git_cert)(unsafe.Pointer(&cx509Cert))
+	}
+
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	chostname := C.CString(hostname)
+	defer C.free(unsafe.Pointer(chostname))
+
+	cvalid := C.int(0)
+	if valid {
+		cvalid = C.int(1)
+	}
+
+	ret := C.git_transport_smart_certificate_check(t.ptr, ccert, cvalid, chostname)
+	if ret != 0 {
+		return MakeGitError(ret)
+	}
+
+	return nil
+}
+
 // SmartSubtransport is the interface for custom subtransports which carry data
 // for the smart transport.
 type SmartSubtransport interface {


Test patch:
diff --git a/git_test.go b/git_test.go
index 101350f..592e06f 100644
--- a/git_test.go
+++ b/git_test.go
@@ -11,6 +11,10 @@ import (
 )
 
 func TestMain(m *testing.M) {
+	if err := registerManagedHTTP(); err != nil {
+		panic(err)
+	}
+
 	ret := m.Run()
 
 	if err := unregisterManagedTransports(); err != nil {
diff --git a/remote_test.go b/remote_test.go
index 7e37274..9660a3f 100644
--- a/remote_test.go
+++ b/remote_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"crypto/rand"
 	"crypto/rsa"
+	"errors"
 	"fmt"
 	"io"
 	"net"
@@ -232,6 +233,31 @@ func TestRemotePrune(t *testing.T) {
 	}
 }
 
+func TestRemoteCredentialsCalled(t *testing.T) {
+	t.Parallel()
+
+	repo := createTestRepo(t)
+	defer cleanupTestRepo(t, repo)
+
+	remote, err := repo.Remotes.CreateAnonymous("https://github.com/libgit2/non-existent")
+	checkFatal(t, err)
+	defer remote.Free()
+
+	errNonExistent := errors.New("non-existent repository")
+	fetchOpts := FetchOptions{
+		RemoteCallbacks: RemoteCallbacks{
+			CredentialsCallback: func(url, username string, allowedTypes CredentialType) (*Credential, error) {
+				return nil, errNonExistent
+			},
+		},
+	}
+
+	err = remote.Fetch(nil, &fetchOpts, "fetch")
+	if err != errNonExistent {
+		t.Fatalf("remote.Fetch() = %v, want %v", err, errNonExistent)
+	}
+}
+
 func newChannelPipe(t *testing.T, w io.Writer, wg *sync.WaitGroup) (*os.File, error) {
 	pr, pw, err := os.Pipe()
 	if err != nil {