github.com/go-asm/go@v1.21.1-0.20240213172139-40c5ead50c48/cmd/go/modfetch/proxy.go (about)

     1  // Copyright 2018 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package modfetch
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"io/fs"
    14  	"net/url"
    15  	"path"
    16  	pathpkg "path"
    17  	"path/filepath"
    18  	"strings"
    19  	"sync"
    20  	"time"
    21  
    22  	"github.com/go-asm/go/cmd/go/base"
    23  	"github.com/go-asm/go/cmd/go/cfg"
    24  	"github.com/go-asm/go/cmd/go/modfetch/codehost"
    25  	"github.com/go-asm/go/cmd/go/web"
    26  
    27  	"golang.org/x/mod/module"
    28  	"golang.org/x/mod/semver"
    29  )
    30  
    31  var HelpGoproxy = &base.Command{
    32  	UsageLine: "goproxy",
    33  	Short:     "module proxy protocol",
    34  	Long: `
    35  A Go module proxy is any web server that can respond to GET requests for
    36  URLs of a specified form. The requests have no query parameters, so even
    37  a site serving from a fixed file system (including a file:/// URL)
    38  can be a module proxy.
    39  
    40  For details on the GOPROXY protocol, see
    41  https://golang.org/ref/mod#goproxy-protocol.
    42  `,
    43  }
    44  
    45  var proxyOnce struct {
    46  	sync.Once
    47  	list []proxySpec
    48  	err  error
    49  }
    50  
    51  type proxySpec struct {
    52  	// url is the proxy URL or one of "off", "direct", "noproxy".
    53  	url string
    54  
    55  	// fallBackOnError is true if a request should be attempted on the next proxy
    56  	// in the list after any error from this proxy. If fallBackOnError is false,
    57  	// the request will only be attempted on the next proxy if the error is
    58  	// equivalent to os.ErrNotFound, which is true for 404 and 410 responses.
    59  	fallBackOnError bool
    60  }
    61  
    62  func proxyList() ([]proxySpec, error) {
    63  	proxyOnce.Do(func() {
    64  		if cfg.GONOPROXY != "" && cfg.GOPROXY != "direct" {
    65  			proxyOnce.list = append(proxyOnce.list, proxySpec{url: "noproxy"})
    66  		}
    67  
    68  		goproxy := cfg.GOPROXY
    69  		for goproxy != "" {
    70  			var url string
    71  			fallBackOnError := false
    72  			if i := strings.IndexAny(goproxy, ",|"); i >= 0 {
    73  				url = goproxy[:i]
    74  				fallBackOnError = goproxy[i] == '|'
    75  				goproxy = goproxy[i+1:]
    76  			} else {
    77  				url = goproxy
    78  				goproxy = ""
    79  			}
    80  
    81  			url = strings.TrimSpace(url)
    82  			if url == "" {
    83  				continue
    84  			}
    85  			if url == "off" {
    86  				// "off" always fails hard, so can stop walking list.
    87  				proxyOnce.list = append(proxyOnce.list, proxySpec{url: "off"})
    88  				break
    89  			}
    90  			if url == "direct" {
    91  				proxyOnce.list = append(proxyOnce.list, proxySpec{url: "direct"})
    92  				// For now, "direct" is the end of the line. We may decide to add some
    93  				// sort of fallback behavior for them in the future, so ignore
    94  				// subsequent entries for forward-compatibility.
    95  				break
    96  			}
    97  
    98  			// Single-word tokens are reserved for built-in behaviors, and anything
    99  			// containing the string ":/" or matching an absolute file path must be a
   100  			// complete URL. For all other paths, implicitly add "https://".
   101  			if strings.ContainsAny(url, ".:/") && !strings.Contains(url, ":/") && !filepath.IsAbs(url) && !path.IsAbs(url) {
   102  				url = "https://" + url
   103  			}
   104  
   105  			// Check that newProxyRepo accepts the URL.
   106  			// It won't do anything with the path.
   107  			if _, err := newProxyRepo(url, "golang.org/x/text"); err != nil {
   108  				proxyOnce.err = err
   109  				return
   110  			}
   111  
   112  			proxyOnce.list = append(proxyOnce.list, proxySpec{
   113  				url:             url,
   114  				fallBackOnError: fallBackOnError,
   115  			})
   116  		}
   117  
   118  		if len(proxyOnce.list) == 0 ||
   119  			len(proxyOnce.list) == 1 && proxyOnce.list[0].url == "noproxy" {
   120  			// There were no proxies, other than the implicit "noproxy" added when
   121  			// GONOPROXY is set. This can happen if GOPROXY is a non-empty string
   122  			// like "," or " ".
   123  			proxyOnce.err = fmt.Errorf("GOPROXY list is not the empty string, but contains no entries")
   124  		}
   125  	})
   126  
   127  	return proxyOnce.list, proxyOnce.err
   128  }
   129  
   130  // TryProxies iterates f over each configured proxy (including "noproxy" and
   131  // "direct" if applicable) until f returns no error or until f returns an
   132  // error that is not equivalent to fs.ErrNotExist on a proxy configured
   133  // not to fall back on errors.
   134  //
   135  // TryProxies then returns that final error.
   136  //
   137  // If GOPROXY is set to "off", TryProxies invokes f once with the argument
   138  // "off".
   139  func TryProxies(f func(proxy string) error) error {
   140  	proxies, err := proxyList()
   141  	if err != nil {
   142  		return err
   143  	}
   144  	if len(proxies) == 0 {
   145  		panic("GOPROXY list is empty")
   146  	}
   147  
   148  	// We try to report the most helpful error to the user. "direct" and "noproxy"
   149  	// errors are best, followed by proxy errors other than ErrNotExist, followed
   150  	// by ErrNotExist.
   151  	//
   152  	// Note that errProxyOff, errNoproxy, and errUseProxy are equivalent to
   153  	// ErrNotExist. errUseProxy should only be returned if "noproxy" is the only
   154  	// proxy. errNoproxy should never be returned, since there should always be a
   155  	// more useful error from "noproxy" first.
   156  	const (
   157  		notExistRank = iota
   158  		proxyRank
   159  		directRank
   160  	)
   161  	var bestErr error
   162  	bestErrRank := notExistRank
   163  	for _, proxy := range proxies {
   164  		err := f(proxy.url)
   165  		if err == nil {
   166  			return nil
   167  		}
   168  		isNotExistErr := errors.Is(err, fs.ErrNotExist)
   169  
   170  		if proxy.url == "direct" || (proxy.url == "noproxy" && err != errUseProxy) {
   171  			bestErr = err
   172  			bestErrRank = directRank
   173  		} else if bestErrRank <= proxyRank && !isNotExistErr {
   174  			bestErr = err
   175  			bestErrRank = proxyRank
   176  		} else if bestErrRank == notExistRank {
   177  			bestErr = err
   178  		}
   179  
   180  		if !proxy.fallBackOnError && !isNotExistErr {
   181  			break
   182  		}
   183  	}
   184  	return bestErr
   185  }
   186  
   187  type proxyRepo struct {
   188  	url          *url.URL // The combined module proxy URL joined with the module path.
   189  	path         string   // The module path (unescaped).
   190  	redactedBase string   // The base module proxy URL in [url.URL.Redacted] form.
   191  
   192  	listLatestOnce sync.Once
   193  	listLatest     *RevInfo
   194  	listLatestErr  error
   195  }
   196  
   197  func newProxyRepo(baseURL, path string) (Repo, error) {
   198  	// Parse the base proxy URL.
   199  	base, err := url.Parse(baseURL)
   200  	if err != nil {
   201  		return nil, err
   202  	}
   203  	redactedBase := base.Redacted()
   204  	switch base.Scheme {
   205  	case "http", "https":
   206  		// ok
   207  	case "file":
   208  		if *base != (url.URL{Scheme: base.Scheme, Path: base.Path, RawPath: base.RawPath}) {
   209  			return nil, fmt.Errorf("invalid file:// proxy URL with non-path elements: %s", redactedBase)
   210  		}
   211  	case "":
   212  		return nil, fmt.Errorf("invalid proxy URL missing scheme: %s", redactedBase)
   213  	default:
   214  		return nil, fmt.Errorf("invalid proxy URL scheme (must be https, http, file): %s", redactedBase)
   215  	}
   216  
   217  	// Append the module path to the URL.
   218  	url := base
   219  	enc, err := module.EscapePath(path)
   220  	if err != nil {
   221  		return nil, err
   222  	}
   223  	url.Path = strings.TrimSuffix(base.Path, "/") + "/" + enc
   224  	url.RawPath = strings.TrimSuffix(base.RawPath, "/") + "/" + pathEscape(enc)
   225  
   226  	return &proxyRepo{url, path, redactedBase, sync.Once{}, nil, nil}, nil
   227  }
   228  
   229  func (p *proxyRepo) ModulePath() string {
   230  	return p.path
   231  }
   232  
   233  var errProxyReuse = fmt.Errorf("proxy does not support CheckReuse")
   234  
   235  func (p *proxyRepo) CheckReuse(ctx context.Context, old *codehost.Origin) error {
   236  	return errProxyReuse
   237  }
   238  
   239  // versionError returns err wrapped in a ModuleError for p.path.
   240  func (p *proxyRepo) versionError(version string, err error) error {
   241  	if version != "" && version != module.CanonicalVersion(version) {
   242  		return &module.ModuleError{
   243  			Path: p.path,
   244  			Err: &module.InvalidVersionError{
   245  				Version: version,
   246  				Pseudo:  module.IsPseudoVersion(version),
   247  				Err:     err,
   248  			},
   249  		}
   250  	}
   251  
   252  	return &module.ModuleError{
   253  		Path:    p.path,
   254  		Version: version,
   255  		Err:     err,
   256  	}
   257  }
   258  
   259  func (p *proxyRepo) getBytes(ctx context.Context, path string) ([]byte, error) {
   260  	body, redactedURL, err := p.getBody(ctx, path)
   261  	if err != nil {
   262  		return nil, err
   263  	}
   264  	defer body.Close()
   265  
   266  	b, err := io.ReadAll(body)
   267  	if err != nil {
   268  		// net/http doesn't add context to Body read errors, so add it here.
   269  		// (See https://go.dev/issue/52727.)
   270  		return b, &url.Error{Op: "read", URL: redactedURL, Err: err}
   271  	}
   272  	return b, nil
   273  }
   274  
   275  func (p *proxyRepo) getBody(ctx context.Context, path string) (r io.ReadCloser, redactedURL string, err error) {
   276  	fullPath := pathpkg.Join(p.url.Path, path)
   277  
   278  	target := *p.url
   279  	target.Path = fullPath
   280  	target.RawPath = pathpkg.Join(target.RawPath, pathEscape(path))
   281  
   282  	resp, err := web.Get(web.DefaultSecurity, &target)
   283  	if err != nil {
   284  		return nil, "", err
   285  	}
   286  	if err := resp.Err(); err != nil {
   287  		resp.Body.Close()
   288  		return nil, "", err
   289  	}
   290  	return resp.Body, resp.URL, nil
   291  }
   292  
   293  func (p *proxyRepo) Versions(ctx context.Context, prefix string) (*Versions, error) {
   294  	data, err := p.getBytes(ctx, "@v/list")
   295  	if err != nil {
   296  		p.listLatestOnce.Do(func() {
   297  			p.listLatest, p.listLatestErr = nil, p.versionError("", err)
   298  		})
   299  		return nil, p.versionError("", err)
   300  	}
   301  	var list []string
   302  	allLine := strings.Split(string(data), "\n")
   303  	for _, line := range allLine {
   304  		f := strings.Fields(line)
   305  		if len(f) >= 1 && semver.IsValid(f[0]) && strings.HasPrefix(f[0], prefix) && !module.IsPseudoVersion(f[0]) {
   306  			list = append(list, f[0])
   307  		}
   308  	}
   309  	p.listLatestOnce.Do(func() {
   310  		p.listLatest, p.listLatestErr = p.latestFromList(ctx, allLine)
   311  	})
   312  	semver.Sort(list)
   313  	return &Versions{List: list}, nil
   314  }
   315  
   316  func (p *proxyRepo) latest(ctx context.Context) (*RevInfo, error) {
   317  	p.listLatestOnce.Do(func() {
   318  		data, err := p.getBytes(ctx, "@v/list")
   319  		if err != nil {
   320  			p.listLatestErr = p.versionError("", err)
   321  			return
   322  		}
   323  		list := strings.Split(string(data), "\n")
   324  		p.listLatest, p.listLatestErr = p.latestFromList(ctx, list)
   325  	})
   326  	return p.listLatest, p.listLatestErr
   327  }
   328  
   329  func (p *proxyRepo) latestFromList(ctx context.Context, allLine []string) (*RevInfo, error) {
   330  	var (
   331  		bestTime    time.Time
   332  		bestVersion string
   333  	)
   334  	for _, line := range allLine {
   335  		f := strings.Fields(line)
   336  		if len(f) >= 1 && semver.IsValid(f[0]) {
   337  			// If the proxy includes timestamps, prefer the timestamp it reports.
   338  			// Otherwise, derive the timestamp from the pseudo-version.
   339  			var (
   340  				ft time.Time
   341  			)
   342  			if len(f) >= 2 {
   343  				ft, _ = time.Parse(time.RFC3339, f[1])
   344  			} else if module.IsPseudoVersion(f[0]) {
   345  				ft, _ = module.PseudoVersionTime(f[0])
   346  			} else {
   347  				// Repo.Latest promises that this method is only called where there are
   348  				// no tagged versions. Ignore any tagged versions that were added in the
   349  				// meantime.
   350  				continue
   351  			}
   352  			if bestTime.Before(ft) {
   353  				bestTime = ft
   354  				bestVersion = f[0]
   355  			}
   356  		}
   357  	}
   358  	if bestVersion == "" {
   359  		return nil, p.versionError("", codehost.ErrNoCommits)
   360  	}
   361  
   362  	// Call Stat to get all the other fields, including Origin information.
   363  	return p.Stat(ctx, bestVersion)
   364  }
   365  
   366  func (p *proxyRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
   367  	encRev, err := module.EscapeVersion(rev)
   368  	if err != nil {
   369  		return nil, p.versionError(rev, err)
   370  	}
   371  	data, err := p.getBytes(ctx, "@v/"+encRev+".info")
   372  	if err != nil {
   373  		return nil, p.versionError(rev, err)
   374  	}
   375  	info := new(RevInfo)
   376  	if err := json.Unmarshal(data, info); err != nil {
   377  		return nil, p.versionError(rev, fmt.Errorf("invalid response from proxy %q: %w", p.redactedBase, err))
   378  	}
   379  	if info.Version != rev && rev == module.CanonicalVersion(rev) && module.Check(p.path, rev) == nil {
   380  		// If we request a correct, appropriate version for the module path, the
   381  		// proxy must return either exactly that version or an error — not some
   382  		// arbitrary other version.
   383  		return nil, p.versionError(rev, fmt.Errorf("proxy returned info for version %s instead of requested version", info.Version))
   384  	}
   385  	return info, nil
   386  }
   387  
   388  func (p *proxyRepo) Latest(ctx context.Context) (*RevInfo, error) {
   389  	data, err := p.getBytes(ctx, "@latest")
   390  	if err != nil {
   391  		if !errors.Is(err, fs.ErrNotExist) {
   392  			return nil, p.versionError("", err)
   393  		}
   394  		return p.latest(ctx)
   395  	}
   396  	info := new(RevInfo)
   397  	if err := json.Unmarshal(data, info); err != nil {
   398  		return nil, p.versionError("", fmt.Errorf("invalid response from proxy %q: %w", p.redactedBase, err))
   399  	}
   400  	return info, nil
   401  }
   402  
   403  func (p *proxyRepo) GoMod(ctx context.Context, version string) ([]byte, error) {
   404  	if version != module.CanonicalVersion(version) {
   405  		return nil, p.versionError(version, fmt.Errorf("internal error: version passed to GoMod is not canonical"))
   406  	}
   407  
   408  	encVer, err := module.EscapeVersion(version)
   409  	if err != nil {
   410  		return nil, p.versionError(version, err)
   411  	}
   412  	data, err := p.getBytes(ctx, "@v/"+encVer+".mod")
   413  	if err != nil {
   414  		return nil, p.versionError(version, err)
   415  	}
   416  	return data, nil
   417  }
   418  
   419  func (p *proxyRepo) Zip(ctx context.Context, dst io.Writer, version string) error {
   420  	if version != module.CanonicalVersion(version) {
   421  		return p.versionError(version, fmt.Errorf("internal error: version passed to Zip is not canonical"))
   422  	}
   423  
   424  	encVer, err := module.EscapeVersion(version)
   425  	if err != nil {
   426  		return p.versionError(version, err)
   427  	}
   428  	path := "@v/" + encVer + ".zip"
   429  	body, redactedURL, err := p.getBody(ctx, path)
   430  	if err != nil {
   431  		return p.versionError(version, err)
   432  	}
   433  	defer body.Close()
   434  
   435  	lr := &io.LimitedReader{R: body, N: codehost.MaxZipFile + 1}
   436  	if _, err := io.Copy(dst, lr); err != nil {
   437  		// net/http doesn't add context to Body read errors, so add it here.
   438  		// (See https://go.dev/issue/52727.)
   439  		err = &url.Error{Op: "read", URL: redactedURL, Err: err}
   440  		return p.versionError(version, err)
   441  	}
   442  	if lr.N <= 0 {
   443  		return p.versionError(version, fmt.Errorf("downloaded zip file too large"))
   444  	}
   445  	return nil
   446  }
   447  
   448  // pathEscape escapes s so it can be used in a path.
   449  // That is, it escapes things like ? and # (which really shouldn't appear anyway).
   450  // It does not escape / to %2F: our REST API is designed so that / can be left as is.
   451  func pathEscape(s string) string {
   452  	return strings.ReplaceAll(url.PathEscape(s), "%2F", "/")
   453  }