package buildstep import ( "context" "fmt" "net/url" "regexp" "strings" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // Option changes FetchCommit. type Option func(*fetchOptions) type fetchOptions struct{ allowFile bool } // AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production // code does not pass it, and FetchCommit then refuses anything but ssh://. func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } } var ( commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`) ) // FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached // (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh:////.git, the ssh name being the // one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names // the pull request's head (refs/pull//head) when the commit is only there. Every git command runs through run // as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no // credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40 // hex digits are refused before anything runs. func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error { var o fetchOptions for _, opt := range opts { opt(&o) } if err := checkFetch(o, dir, remote, commit, ref); err != nil { return err } env := []string{"GIT_TERMINAL_PROMPT=0"} git := func(args ...string) (string, error) { full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr)) } return r.Stdout, nil } if _, err := git("init", "-q"); err != nil { return err } if _, err := git("remote", "add", "origin", remote); err != nil { return err } if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil { if ref == "" { return err } if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil { return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) } if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil { return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr) } } if _, err := git("checkout", "-q", "--detach", commit); err != nil { return err } out, err := git("rev-parse", "HEAD") if err != nil { return err } if head := strings.TrimSpace(out); head != commit { return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit) } return nil } func checkFetch(o fetchOptions, dir, remote, commit, ref string) error { if dir == "" { return fmt.Errorf("buildstep: no workspace directory") } if !commitOK.MatchString(commit) { return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit) } if ref != "" && !refOK.MatchString(ref) { return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref) } u, err := url.Parse(remote) if err != nil { return fmt.Errorf("buildstep: the remote is not a URL: %w", err) } if u.User != nil { return fmt.Errorf("buildstep: the remote carries user information; the key is the credential") } switch { case u.Scheme == "ssh" && u.Host != "": case u.Scheme == "file" && o.allowFile: default: return fmt.Errorf("buildstep: the remote %q is not ssh://", remote) } return nil } func firstLine(s string) string { for _, l := range strings.Split(s, "\n") { if l = strings.TrimSpace(l); l != "" { return l } } return "" }