// Copyright 2018 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package codehost defines the interface implemented by a code hosting source, // along with support code for use by implementations. package codehost import ( "bytes" "crypto/sha256" "fmt" exec "internal/execabs" "io" "io/fs" "os" "path/filepath" "strings" "sync" "time" "cmd/go/internal/cfg" "cmd/go/internal/lockedfile" "cmd/go/internal/str" ) // Downloaded size limits. const ( MaxGoMod = 16 << 20 // maximum size of go.mod file MaxLICENSE = 16 << 20 // maximum size of LICENSE file MaxZipFile = 500 << 20 // maximum size of downloaded zip file ) // A Repo represents a code hosting source. // Typical implementations include local version control repositories, // remote version control servers, and code hosting sites. // A Repo must be safe for simultaneous use by multiple goroutines. type Repo interface { // List lists all tags with the given prefix. Tags(prefix string) (tags []string, err error) // Stat returns information about the revision rev. // A revision can be any identifier known to the underlying service: // commit hash, branch, tag, and so on. Stat(rev string) (*RevInfo, error) // Latest returns the latest revision on the default branch, // whatever that means in the underlying implementation. Latest() (*RevInfo, error) // ReadFile reads the given file in the file tree corresponding to revision rev. // It should refuse to read more than maxSize bytes. // // If the requested file does not exist it should return an error for which // os.IsNotExist(err) returns true. ReadFile(rev, file string, maxSize int64) (data []byte, err error) // ReadZip downloads a zip file for the subdir subdirectory // of the given revision to a new file in a given temporary directory. // It should refuse to read more than maxSize bytes. // It returns a ReadCloser for a streamed copy of the zip file. // All files in the zip file are expected to be // nested in a single top-level directory, whose name is not specified. ReadZip(rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) // RecentTag returns the most recent tag on rev or one of its predecessors // with the given prefix. allowed may be used to filter out unwanted versions. RecentTag(rev, prefix string, allowed func(string) bool) (tag string, err error) // DescendsFrom reports whether rev or any of its ancestors has the given tag. // // DescendsFrom must return true for any tag returned by RecentTag for the // same revision. DescendsFrom(rev, tag string) (bool, error) } // A Rev describes a single revision in a source code repository. type RevInfo struct { Name string // complete ID in underlying repository Short string // shortened ID, for use in pseudo-version Version string // version used in lookup Time time.Time // commit time Tags []string // known tags for commit } // A FileRev describes the result of reading a file at a given revision. type FileRev struct { Rev string // requested revision Data []byte // file data Err error // error if any; os.IsNotExist(Err)==true if rev exists but file does not exist in that rev } // UnknownRevisionError is an error equivalent to fs.ErrNotExist, but for a // revision rather than a file. type UnknownRevisionError struct { Rev string } func (e *UnknownRevisionError) Error() string { return "unknown revision " + e.Rev } func (UnknownRevisionError) Is(err error) bool { return err == fs.ErrNotExist } // ErrNoCommits is an error equivalent to fs.ErrNotExist indicating that a given // repository or module contains no commits. var ErrNoCommits error = noCommitsError{} type noCommitsError struct{} func (noCommitsError) Error() string { return "no commits" } func (noCommitsError) Is(err error) bool { return err == fs.ErrNotExist } // AllHex reports whether the revision rev is entirely lower-case hexadecimal digits. func AllHex(rev string) bool { for i := 0; i < len(rev); i++ { c := rev[i] if '0' <= c && c <= '9' || 'a' <= c && c <= 'f' { continue } return false } return true } // ShortenSHA1 shortens a SHA1 hash (40 hex digits) to the canonical length // used in pseudo-versions (12 hex digits). func ShortenSHA1(rev string) string { if AllHex(rev) && len(rev) == 40 { return rev[:12] } return rev } // WorkDir returns the name of the cached work directory to use for the // given repository type and name. func WorkDir(typ, name string) (dir, lockfile string, err error) { if cfg.GOMODCACHE == "" { return "", "", fmt.Errorf("neither GOPATH nor GOMODCACHE are set") } // We name the work directory for the SHA256 hash of the type and name. // We intentionally avoid the actual name both because of possible // conflicts with valid file system paths and because we want to ensure // that one checkout is never nested inside another. That nesting has // led to security problems in the past. if strings.Contains(typ, ":") { return "", "", fmt.Errorf("codehost.WorkDir: type cannot contain colon") } key := typ + ":" + name dir = filepath.Join(cfg.GOMODCACHE, "cache/vcs", fmt.Sprintf("%x", sha256.Sum256([]byte(key)))) if cfg.BuildX { fmt.Fprintf(os.Stderr, "mkdir -p %s # %s %s\n", filepath.Dir(dir), typ, name) } if err := os.MkdirAll(filepath.Dir(dir), 0777); err != nil { return "", "", err } lockfile = dir + ".lock" if cfg.BuildX { fmt.Fprintf(os.Stderr, "# lock %s", lockfile) } unlock, err := lockedfile.MutexAt(lockfile).Lock() if err != nil { return "", "", fmt.Errorf("codehost.WorkDir: can't find or create lock file: %v", err) } defer unlock() data, err := os.ReadFile(dir + ".info") info, err2 := os.Stat(dir) if err == nil && err2 == nil && info.IsDir() { // Info file and directory both already exist: reuse. have := strings.TrimSuffix(string(data), "\n") if have != key { return "", "", fmt.Errorf("%s exists with wrong content (have %q want %q)", dir+".info", have, key) } if cfg.BuildX { fmt.Fprintf(os.Stderr, "# %s for %s %s\n", dir, typ, name) } return dir, lockfile, nil } // Info file or directory missing. Start from scratch. if cfg.BuildX { fmt.Fprintf(os.Stderr, "mkdir -p %s # %s %s\n", dir, typ, name) } os.RemoveAll(dir) if err := os.MkdirAll(dir, 0777); err != nil { return "", "", err } if err := os.WriteFile(dir+".info", []byte(key), 0666); err != nil { os.RemoveAll(dir) return "", "", err } return dir, lockfile, nil } type RunError struct { Cmd string Err error Stderr []byte HelpText string } func (e *RunError) Error() string { text := e.Cmd + ": " + e.Err.Error() stderr := bytes.TrimRight(e.Stderr, "\n") if len(stderr) > 0 { text += ":\n\t" + strings.ReplaceAll(string(stderr), "\n", "\n\t") } if len(e.HelpText) > 0 { text += "\n" + e.HelpText } return text } var dirLock sync.Map // Run runs the command line in the given directory // (an empty dir means the current directory). // It returns the standard output and, for a non-zero exit, // a *RunError indicating the command, exit status, and standard error. // Standard error is unavailable for commands that exit successfully. func Run(dir string, cmdline ...any) ([]byte, error) { return RunWithStdin(dir, nil, cmdline...) } // bashQuoter escapes characters that have special meaning in double-quoted strings in the bash shell. // See https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html. var bashQuoter = strings.NewReplacer(`"`, `\"`, `$`, `\$`, "`", "\\`", `\`, `\\`) func RunWithStdin(dir string, stdin io.Reader, cmdline ...any) ([]byte, error) { if dir != "" { muIface, ok := dirLock.Load(dir) if !ok { muIface, _ = dirLock.LoadOrStore(dir, new(sync.Mutex)) } mu := muIface.(*sync.Mutex) mu.Lock() defer mu.Unlock() } cmd := str.StringList(cmdline...) if os.Getenv("TESTGOVCS") == "panic" { panic(fmt.Sprintf("use of vcs: %v", cmd)) } if cfg.BuildX { text := new(strings.Builder) if dir != "" { text.WriteString("cd ") text.WriteString(dir) text.WriteString("; ") } for i, arg := range cmd { if i > 0 { text.WriteByte(' ') } switch { case strings.ContainsAny(arg, "'"): // Quote args that could be mistaken for quoted args. text.WriteByte('"') text.WriteString(bashQuoter.Replace(arg)) text.WriteByte('"') case strings.ContainsAny(arg, "$`\\*?[\"\t\n\v\f\r \u0085\u00a0"): // Quote args that contain special characters, glob patterns, or spaces. text.WriteByte('\'') text.WriteString(arg) text.WriteByte('\'') default: text.WriteString(arg) } } fmt.Fprintf(os.Stderr, "%s\n", text) start := time.Now() defer func() { fmt.Fprintf(os.Stderr, "%.3fs # %s\n", time.Since(start).Seconds(), text) }() } // TODO: Impose limits on command output size. // TODO: Set environment to get English error messages. var stderr bytes.Buffer var stdout bytes.Buffer c := exec.Command(cmd[0], cmd[1:]...) c.Dir = dir c.Stdin = stdin c.Stderr = &stderr c.Stdout = &stdout err := c.Run() if err != nil { err = &RunError{Cmd: strings.Join(cmd, " ") + " in " + dir, Stderr: stderr.Bytes(), Err: err} } return stdout.Bytes(), err }