github.com/sirkon/goproxy@v1.4.8/internal/modfetch/codehost/vcs.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 codehost
     6  
     7  import (
     8  	"encoding/xml"
     9  	"fmt"
    10  	"io"
    11  	"io/ioutil"
    12  	"os"
    13  	"path/filepath"
    14  	"regexp"
    15  	"sort"
    16  	"strconv"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"github.com/sirkon/goproxy/internal/par"
    22  	"github.com/sirkon/goproxy/internal/str"
    23  )
    24  
    25  // A VCSError indicates an error using a version control system.
    26  // The implication of a VCSError is that we know definitively where
    27  // to get the code, but we can't access it due to the error.
    28  // The caller should report this error instead of continuing to probe
    29  // other possible module paths.
    30  type VCSError struct {
    31  	Err error
    32  }
    33  
    34  func (e *VCSError) Error() string { return e.Err.Error() }
    35  
    36  func NewRepo(vcs, remote string) (Repo, error) {
    37  	type key struct {
    38  		vcs    string
    39  		remote string
    40  	}
    41  	type cached struct {
    42  		repo Repo
    43  		err  error
    44  	}
    45  	c := vcsRepoCache.Do(key{vcs, remote}, func() interface{} {
    46  		repo, err := newVCSRepo(vcs, remote)
    47  		if err != nil {
    48  			err = &VCSError{err}
    49  		}
    50  		return cached{repo, err}
    51  	}).(cached)
    52  
    53  	return c.repo, c.err
    54  }
    55  
    56  var vcsRepoCache par.Cache
    57  
    58  type vcsRepo struct {
    59  	remote string
    60  	cmd    *vcsCmd
    61  	dir    string
    62  
    63  	tagsOnce sync.Once
    64  	tags     map[string]bool
    65  
    66  	branchesOnce sync.Once
    67  	branches     map[string]bool
    68  
    69  	fetchOnce sync.Once
    70  	fetchErr  error
    71  }
    72  
    73  func newVCSRepo(vcs, remote string) (Repo, error) {
    74  	if vcs == "git" {
    75  		return newGitRepo(remote, false)
    76  	}
    77  	cmd := vcsCmds[vcs]
    78  	if cmd == nil {
    79  		return nil, fmt.Errorf("unknown vcs: %s %s", vcs, remote)
    80  	}
    81  	if !strings.Contains(remote, "://") {
    82  		return nil, fmt.Errorf("invalid vcs remote: %s %s", vcs, remote)
    83  	}
    84  	r := &vcsRepo{remote: remote, cmd: cmd}
    85  	if cmd.init == nil {
    86  		return r, nil
    87  	}
    88  	dir, err := WorkDir(vcsWorkDirType+vcs, r.remote)
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  	r.dir = dir
    93  	if _, err := os.Stat(filepath.Join(dir, "."+vcs)); err != nil {
    94  		if _, err := Run(dir, cmd.init(r.remote)); err != nil {
    95  			os.RemoveAll(dir)
    96  			return nil, err
    97  		}
    98  	}
    99  	return r, nil
   100  }
   101  
   102  const vcsWorkDirType = "vcs1."
   103  
   104  type vcsCmd struct {
   105  	vcs           string                                            // vcs name "hg"
   106  	init          func(remote string) []string                      // cmd to init repo to track remote
   107  	tags          func(remote string) []string                      // cmd to list local tags
   108  	tagRE         *regexp.Regexp                                    // regexp to extract tag names from output of tags cmd
   109  	branches      func(remote string) []string                      // cmd to list local branches
   110  	branchRE      *regexp.Regexp                                    // regexp to extract branch names from output of tags cmd
   111  	badLocalRevRE *regexp.Regexp                                    // regexp of names that must not be served out of local cache without doing fetch first
   112  	statLocal     func(rev, remote string) []string                 // cmd to stat local rev
   113  	parseStat     func(rev, out string) (*RevInfo, error)           // cmd to parse output of statLocal
   114  	fetch         []string                                          // cmd to fetch everything from remote
   115  	latest        string                                            // name of latest commit on remote (tip, HEAD, etc)
   116  	readFile      func(rev, file, remote string) []string           // cmd to read rev's file
   117  	readZip       func(rev, subdir, remote, target string) []string // cmd to read rev's subdir as zip file
   118  }
   119  
   120  var re = regexp.MustCompile
   121  
   122  var vcsCmds = map[string]*vcsCmd{
   123  	"hg": {
   124  		vcs: "hg",
   125  		init: func(remote string) []string {
   126  			return []string{"hg", "clone", "-U", remote, "."}
   127  		},
   128  		tags: func(remote string) []string {
   129  			return []string{"hg", "tags", "-q"}
   130  		},
   131  		tagRE: re(`(?m)^[^\n]+$`),
   132  		branches: func(remote string) []string {
   133  			return []string{"hg", "branches", "-c", "-q"}
   134  		},
   135  		branchRE:      re(`(?m)^[^\n]+$`),
   136  		badLocalRevRE: re(`(?m)^(tip)$`),
   137  		statLocal: func(rev, remote string) []string {
   138  			return []string{"hg", "log", "-l1", "-r", rev, "--template", "{node} {date|hgdate} {tags}"}
   139  		},
   140  		parseStat: hgParseStat,
   141  		fetch:     []string{"hg", "pull", "-f"},
   142  		latest:    "tip",
   143  		readFile: func(rev, file, remote string) []string {
   144  			return []string{"hg", "cat", "-r", rev, file}
   145  		},
   146  		readZip: func(rev, subdir, remote, target string) []string {
   147  			pattern := []string{}
   148  			if subdir != "" {
   149  				pattern = []string{"-I", subdir + "/**"}
   150  			}
   151  			return str.StringList("hg", "archive", "-t", "zip", "--no-decode", "-r", rev, "--prefix=prefix/", pattern, target)
   152  		},
   153  	},
   154  
   155  	"svn": {
   156  		vcs:  "svn",
   157  		init: nil, // no local checkout
   158  		tags: func(remote string) []string {
   159  			return []string{"svn", "list", strings.TrimSuffix(remote, "/trunk") + "/tags"}
   160  		},
   161  		tagRE: re(`(?m)^(.*?)/?$`),
   162  		statLocal: func(rev, remote string) []string {
   163  			suffix := "@" + rev
   164  			if rev == "latest" {
   165  				suffix = ""
   166  			}
   167  			return []string{"svn", "log", "-l1", "--xml", remote + suffix}
   168  		},
   169  		parseStat: svnParseStat,
   170  		latest:    "latest",
   171  		readFile: func(rev, file, remote string) []string {
   172  			return []string{"svn", "cat", remote + "/" + file + "@" + rev}
   173  		},
   174  		// TODO: zip
   175  	},
   176  
   177  	"bzr": {
   178  		vcs: "bzr",
   179  		init: func(remote string) []string {
   180  			return []string{"bzr", "branch", "--use-existing-dir", remote, "."}
   181  		},
   182  		fetch: []string{
   183  			"bzr", "pull", "--overwrite-tags",
   184  		},
   185  		tags: func(remote string) []string {
   186  			return []string{"bzr", "tags"}
   187  		},
   188  		tagRE:         re(`(?m)^\S+`),
   189  		badLocalRevRE: re(`^revno:-`),
   190  		statLocal: func(rev, remote string) []string {
   191  			return []string{"bzr", "log", "-l1", "--long", "--show-ids", "-r", rev}
   192  		},
   193  		parseStat: bzrParseStat,
   194  		latest:    "revno:-1",
   195  		readFile: func(rev, file, remote string) []string {
   196  			return []string{"bzr", "cat", "-r", rev, file}
   197  		},
   198  		readZip: func(rev, subdir, remote, target string) []string {
   199  			extra := []string{}
   200  			if subdir != "" {
   201  				extra = []string{"./" + subdir}
   202  			}
   203  			return str.StringList("bzr", "export", "--format=zip", "-r", rev, "--root=prefix/", target, extra)
   204  		},
   205  	},
   206  
   207  	"fossil": {
   208  		vcs: "fossil",
   209  		init: func(remote string) []string {
   210  			return []string{"fossil", "clone", remote, ".fossil"}
   211  		},
   212  		fetch: []string{"fossil", "pull", "-R", ".fossil"},
   213  		tags: func(remote string) []string {
   214  			return []string{"fossil", "tag", "-R", ".fossil", "list"}
   215  		},
   216  		tagRE: re(`XXXTODO`),
   217  		statLocal: func(rev, remote string) []string {
   218  			return []string{"fossil", "info", "-R", ".fossil", rev}
   219  		},
   220  		parseStat: fossilParseStat,
   221  		latest:    "trunk",
   222  		readFile: func(rev, file, remote string) []string {
   223  			return []string{"fossil", "cat", "-R", ".fossil", "-r", rev, file}
   224  		},
   225  		readZip: func(rev, subdir, remote, target string) []string {
   226  			extra := []string{}
   227  			if subdir != "" && !strings.ContainsAny(subdir, "*?[],") {
   228  				extra = []string{"--include", subdir}
   229  			}
   230  			// Note that vcsRepo.ReadZip below rewrites this command
   231  			// to run in a different directory, to work around a fossil bug.
   232  			return str.StringList("fossil", "zip", "-R", ".fossil", "--name", "prefix", extra, rev, target)
   233  		},
   234  	},
   235  }
   236  
   237  func (r *vcsRepo) loadTags() {
   238  	out, err := Run(r.dir, r.cmd.tags(r.remote))
   239  	if err != nil {
   240  		return
   241  	}
   242  
   243  	// Run tag-listing command and extract tags.
   244  	r.tags = make(map[string]bool)
   245  	for _, tag := range r.cmd.tagRE.FindAllString(string(out), -1) {
   246  		if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(tag) {
   247  			continue
   248  		}
   249  		r.tags[tag] = true
   250  	}
   251  }
   252  
   253  func (r *vcsRepo) loadBranches() {
   254  	if r.cmd.branches == nil {
   255  		return
   256  	}
   257  
   258  	out, err := Run(r.dir, r.cmd.branches(r.remote))
   259  	if err != nil {
   260  		return
   261  	}
   262  
   263  	r.branches = make(map[string]bool)
   264  	for _, branch := range r.cmd.branchRE.FindAllString(string(out), -1) {
   265  		if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(branch) {
   266  			continue
   267  		}
   268  		r.branches[branch] = true
   269  	}
   270  }
   271  
   272  func (r *vcsRepo) Tags(prefix string) ([]string, error) {
   273  	r.tagsOnce.Do(r.loadTags)
   274  
   275  	tags := []string{}
   276  	for tag := range r.tags {
   277  		if strings.HasPrefix(tag, prefix) {
   278  			tags = append(tags, tag)
   279  		}
   280  	}
   281  	sort.Strings(tags)
   282  	return tags, nil
   283  }
   284  
   285  func (r *vcsRepo) Stat(rev string) (*RevInfo, error) {
   286  	if rev == "latest" {
   287  		rev = r.cmd.latest
   288  	}
   289  	r.branchesOnce.Do(r.loadBranches)
   290  	revOK := (r.cmd.badLocalRevRE == nil || !r.cmd.badLocalRevRE.MatchString(rev)) && !r.branches[rev]
   291  	if revOK {
   292  		if info, err := r.statLocal(rev); err == nil {
   293  			return info, nil
   294  		}
   295  	}
   296  
   297  	r.fetchOnce.Do(r.fetch)
   298  	if r.fetchErr != nil {
   299  		return nil, r.fetchErr
   300  	}
   301  	info, err := r.statLocal(rev)
   302  	if err != nil {
   303  		return nil, err
   304  	}
   305  	if !revOK {
   306  		info.Version = info.Name
   307  	}
   308  	return info, nil
   309  }
   310  
   311  func (r *vcsRepo) fetch() {
   312  	_, r.fetchErr = Run(r.dir, r.cmd.fetch)
   313  }
   314  
   315  func (r *vcsRepo) statLocal(rev string) (*RevInfo, error) {
   316  	out, err := Run(r.dir, r.cmd.statLocal(rev, r.remote))
   317  	if err != nil {
   318  		return nil, fmt.Errorf("unknown revision %s", rev)
   319  	}
   320  	return r.cmd.parseStat(rev, string(out))
   321  }
   322  
   323  func (r *vcsRepo) Latest() (*RevInfo, error) {
   324  	return r.Stat("latest")
   325  }
   326  
   327  func (r *vcsRepo) ReadFile(rev, file string, maxSize int64) ([]byte, error) {
   328  	if rev == "latest" {
   329  		rev = r.cmd.latest
   330  	}
   331  	_, err := r.Stat(rev) // download rev into local repo
   332  	if err != nil {
   333  		return nil, err
   334  	}
   335  	out, err := Run(r.dir, r.cmd.readFile(rev, file, r.remote))
   336  	if err != nil {
   337  		return nil, os.ErrNotExist
   338  	}
   339  	return out, nil
   340  }
   341  
   342  func (r *vcsRepo) ReadFileRevs(revs []string, file string, maxSize int64) (map[string]*FileRev, error) {
   343  	return nil, fmt.Errorf("ReadFileRevs not implemented")
   344  }
   345  
   346  func (r *vcsRepo) RecentTag(rev, prefix string) (tag string, err error) {
   347  	return "", fmt.Errorf("RecentTags not implemented")
   348  }
   349  
   350  func (r *vcsRepo) ReadZip(rev, subdir string, maxSize int64) (zip io.ReadCloser, actualSubdir string, err error) {
   351  	if rev == "latest" {
   352  		rev = r.cmd.latest
   353  	}
   354  	f, err := ioutil.TempFile("", "go-readzip-*.zip")
   355  	if err != nil {
   356  		return nil, "", err
   357  	}
   358  	if r.cmd.vcs == "fossil" {
   359  		// If you run
   360  		//	fossil zip -R .fossil --name prefix trunk /tmp/x.zip
   361  		// fossil fails with "unable to create directory /tmp" [sic].
   362  		// Change the command to run in /tmp instead,
   363  		// replacing the -R argument with an absolute path.
   364  		args := r.cmd.readZip(rev, subdir, r.remote, filepath.Base(f.Name()))
   365  		for i := range args {
   366  			if args[i] == ".fossil" {
   367  				args[i] = filepath.Join(r.dir, ".fossil")
   368  			}
   369  		}
   370  		_, err = Run(filepath.Dir(f.Name()), args)
   371  	} else {
   372  		_, err = Run(r.dir, r.cmd.readZip(rev, subdir, r.remote, f.Name()))
   373  	}
   374  	if err != nil {
   375  		f.Close()
   376  		os.Remove(f.Name())
   377  		return nil, "", err
   378  	}
   379  	return &deleteCloser{f}, "", nil
   380  }
   381  
   382  // deleteCloser is a file that gets deleted on Close.
   383  type deleteCloser struct {
   384  	*os.File
   385  }
   386  
   387  func (d *deleteCloser) Close() error {
   388  	defer os.Remove(d.File.Name())
   389  	return d.File.Close()
   390  }
   391  
   392  func hgParseStat(rev, out string) (*RevInfo, error) {
   393  	f := strings.Fields(string(out))
   394  	if len(f) < 3 {
   395  		return nil, fmt.Errorf("unexpected response from hg log: %q", out)
   396  	}
   397  	hash := f[0]
   398  	version := rev
   399  	if strings.HasPrefix(hash, version) {
   400  		version = hash // extend to full hash
   401  	}
   402  	t, err := strconv.ParseInt(f[1], 10, 64)
   403  	if err != nil {
   404  		return nil, fmt.Errorf("invalid time from hg log: %q", out)
   405  	}
   406  
   407  	var tags []string
   408  	for _, tag := range f[3:] {
   409  		if tag != "tip" {
   410  			tags = append(tags, tag)
   411  		}
   412  	}
   413  	sort.Strings(tags)
   414  
   415  	info := &RevInfo{
   416  		Name:    hash,
   417  		Short:   ShortenSHA1(hash),
   418  		Time:    time.Unix(t, 0).UTC(),
   419  		Version: version,
   420  		Tags:    tags,
   421  	}
   422  	return info, nil
   423  }
   424  
   425  func svnParseStat(rev, out string) (*RevInfo, error) {
   426  	var log struct {
   427  		Logentry struct {
   428  			Revision int64  `xml:"revision,attr"`
   429  			Date     string `xml:"date"`
   430  		} `xml:"logentry"`
   431  	}
   432  	if err := xml.Unmarshal([]byte(out), &log); err != nil {
   433  		return nil, fmt.Errorf("unexpected response from svn log --xml: %v\n%s", err, out)
   434  	}
   435  
   436  	t, err := time.Parse(time.RFC3339, log.Logentry.Date)
   437  	if err != nil {
   438  		return nil, fmt.Errorf("unexpected response from svn log --xml: %v\n%s", err, out)
   439  	}
   440  
   441  	info := &RevInfo{
   442  		Name:    fmt.Sprintf("%d", log.Logentry.Revision),
   443  		Short:   fmt.Sprintf("%012d", log.Logentry.Revision),
   444  		Time:    t.UTC(),
   445  		Version: rev,
   446  	}
   447  	return info, nil
   448  }
   449  
   450  func bzrParseStat(rev, out string) (*RevInfo, error) {
   451  	var revno int64
   452  	var tm time.Time
   453  	for _, line := range strings.Split(out, "\n") {
   454  		if line == "" || line[0] == ' ' || line[0] == '\t' {
   455  			// End of header, start of commit message.
   456  			break
   457  		}
   458  		if line[0] == '-' {
   459  			continue
   460  		}
   461  		i := strings.Index(line, ":")
   462  		if i < 0 {
   463  			// End of header, start of commit message.
   464  			break
   465  		}
   466  		key, val := line[:i], strings.TrimSpace(line[i+1:])
   467  		switch key {
   468  		case "revno":
   469  			if j := strings.Index(val, " "); j >= 0 {
   470  				val = val[:j]
   471  			}
   472  			i, err := strconv.ParseInt(val, 10, 64)
   473  			if err != nil {
   474  				return nil, fmt.Errorf("unexpected revno from bzr log: %q", line)
   475  			}
   476  			revno = i
   477  		case "timestamp":
   478  			j := strings.Index(val, " ")
   479  			if j < 0 {
   480  				return nil, fmt.Errorf("unexpected timestamp from bzr log: %q", line)
   481  			}
   482  			t, err := time.Parse("2006-01-02 15:04:05 -0700", val[j+1:])
   483  			if err != nil {
   484  				return nil, fmt.Errorf("unexpected timestamp from bzr log: %q", line)
   485  			}
   486  			tm = t.UTC()
   487  		}
   488  	}
   489  	if revno == 0 || tm.IsZero() {
   490  		return nil, fmt.Errorf("unexpected response from bzr log: %q", out)
   491  	}
   492  
   493  	info := &RevInfo{
   494  		Name:    fmt.Sprintf("%d", revno),
   495  		Short:   fmt.Sprintf("%012d", revno),
   496  		Time:    tm,
   497  		Version: rev,
   498  	}
   499  	return info, nil
   500  }
   501  
   502  func fossilParseStat(rev, out string) (*RevInfo, error) {
   503  	for _, line := range strings.Split(out, "\n") {
   504  		if strings.HasPrefix(line, "uuid:") {
   505  			f := strings.Fields(line)
   506  			if len(f) != 5 || len(f[1]) != 40 || f[4] != "UTC" {
   507  				return nil, fmt.Errorf("unexpected response from fossil info: %q", line)
   508  			}
   509  			t, err := time.Parse("2006-01-02 15:04:05", f[2]+" "+f[3])
   510  			if err != nil {
   511  				return nil, fmt.Errorf("unexpected response from fossil info: %q", line)
   512  			}
   513  			hash := f[1]
   514  			version := rev
   515  			if strings.HasPrefix(hash, version) {
   516  				version = hash // extend to full hash
   517  			}
   518  			info := &RevInfo{
   519  				Name:    hash,
   520  				Short:   ShortenSHA1(hash),
   521  				Time:    t,
   522  				Version: version,
   523  			}
   524  			return info, nil
   525  		}
   526  	}
   527  	return nil, fmt.Errorf("unexpected response from fossil info: %q", out)
   528  }