package buildstep import ( "context" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // Option changes FetchCommit. type Option func(*fetchOptions) type fetchOptions struct { allowFile bool mirror string } // 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 } } // WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps // in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates // names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never // a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it. func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } } // useMirror writes the alternates file of the repository in dir, when a mirror is set. func useMirror(o fetchOptions, dir string) error { if o.mirror == "" { return nil } info := filepath.Join(dir, ".git", "objects", "info") if err := os.MkdirAll(info, 0o755); err != nil { return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) } line := filepath.Join(o.mirror, "objects") + "\n" if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil { return fmt.Errorf("buildstep: the mirror cannot be used: %w", err) } return nil } 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 := useMirror(o, dir); 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 } // UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare, // origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as // FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror // keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers // serialise updates of one mirror. func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error { var o fetchOptions for _, opt := range opts { opt(&o) } if !filepath.IsAbs(mirror) { return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror) } if err := checkFetch(o, mirror, remote, commit, ref); err != nil { return err } env := []string{"GIT_TERMINAL_PROMPT=0"} git := func(args ...string) error { r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...) if r.Status != 0 || r.Err != "" { return fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr)) } return nil } in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) } if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil { if err := git("init", "-q", "--bare", mirror); err != nil { return err } if err := in("remote", "add", "origin", remote); err != nil { return err } } else if err := in("remote", "set-url", "origin", remote); err != nil { return err } if err := in("fetch", "--no-tags", "origin", commit); err != nil { if ref == "" { return err } if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil { return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr) } } if err := in("cat-file", "-e", commit+"^{commit}"); err != nil { return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err) } return nil } func checkFetch(o fetchOptions, dir, remote, commit, ref string) error { if dir == "" { return fmt.Errorf("buildstep: no workspace directory") } if o.mirror != "" { if !filepath.IsAbs(o.mirror) { return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror) } if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() { return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror) } } 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 "" }