Skip to content
quarry Artifact [b2d7c97a16]
Login

Artifact [b2d7c97a16]

Artifact b2d7c97a1650b25c2cf915875f261ebe8c1c355226257a2f8f2bedf4fd48e094:


package runner

import (
	"archive/tar"
	"compress/gzip"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
)

// ArchiveCheckout fetches a check-in's tree as the forge's own archive
// (GET <repo>/archive/<checkin>, a gzip tarball) and unpacks it into a
// fresh directory. It needs nothing on the machine but this program:
// no zit, no clone, no history. A task that needs a real repository
// uses ZitCheckout instead.
type ArchiveCheckout struct {
	URL string // the repository's URL on the forge
	// User and Password, for a private repository: an account that may
	// read it and that account's sync token.
	User, Password string
	Client         *http.Client
}

// Checkout replaces dir with checkin's tree. The tree is unpacked
// beside dir and swapped in only once it is whole, so a failed fetch
// never leaves half of one check-in over the rest of another.
func (a ArchiveCheckout) Checkout(ctx context.Context, dir, checkin string) error {
	req, err := http.NewRequestWithContext(ctx, "GET", strings.TrimSuffix(a.URL, "/")+"/archive/"+url.PathEscape(checkin), nil)
	if err != nil {
		return err
	}
	if a.User != "" {
		req.SetBasicAuth(a.User, a.Password)
	}
	c := a.Client
	if c == nil {
		c = http.DefaultClient
	}
	resp, err := c.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
		if c, ok := mergeConflictIn(resp.StatusCode, msg); ok {
			return c
		}
		if len(msg) > 256 {
			msg = msg[:256]
		}
		return fmt.Errorf("archive of %.12s: %d %s", checkin, resp.StatusCode, strings.TrimSpace(string(msg)))
	}
	tmp := dir + ".new"
	os.RemoveAll(tmp)
	if err := os.MkdirAll(tmp, 0o755); err != nil {
		return err
	}
	if err := untar(resp.Body, tmp); err != nil {
		os.RemoveAll(tmp)
		return fmt.Errorf("archive of %.12s: %w", checkin, err)
	}
	if err := os.RemoveAll(dir); err != nil {
		return err
	}
	return os.Rename(tmp, dir)
}

// MergeConflict is a premerge pair's verdict arriving as its checkout:
// the branch does not merge into the target, so there is no tree to
// test (RFC 0009 §premerge). It fails the task at once, never retried,
// because trying again asks the same two fixed trees the same question.
type MergeConflict struct {
	Message string
	Paths   []string
}

func (c *MergeConflict) Error() string {
	if len(c.Paths) == 0 {
		return c.Message
	}
	return c.Message + "\n  " + strings.Join(c.Paths, "\n  ")
}

// mergeConflictIn reads the forge's refusal of a pair archive.
func mergeConflictIn(status int, body []byte) (*MergeConflict, bool) {
	if status != http.StatusBadRequest {
		return nil, false
	}
	var r struct {
		Code  string `json:"code"`
		Error string `json:"error"`
		Paths string `json:"paths"`
	}
	if json.Unmarshal(body, &r) != nil || r.Code != "merge_conflict" {
		return nil, false
	}
	c := &MergeConflict{Message: r.Error}
	for _, p := range strings.Split(r.Paths, "\n") {
		if p = strings.TrimSpace(p); p != "" {
			c.Paths = append(c.Paths, p)
		}
	}
	return c, true
}

// IsPair reports a premerge subject, "pair:<branch tip>:<target tip>".
func IsPair(checkin string) bool { return strings.HasPrefix(checkin, "pair:") }

// untar unpacks a gzip tarball into root, dropping the archive's single
// top-level directory (the forge names it <repo>-<checkin>/). Every
// path must land inside root: an absolute name, a "..", or a link that
// points out of the tree is refused rather than skipped, because a
// tree with a file silently missing is a test result nobody can trust.
func untar(r io.Reader, root string) error {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	for {
		h, err := tr.Next()
		if errors.Is(err, io.EOF) {
			return nil
		}
		if err != nil {
			return err
		}
		name := strings.TrimPrefix(h.Name, "./")
		_, rel, ok := strings.Cut(name, "/")
		if !ok || rel == "" || strings.Trim(rel, "/") == "" {
			continue // the top-level directory itself
		}
		rel = strings.TrimSuffix(rel, "/")
		if !filepath.IsLocal(filepath.FromSlash(rel)) {
			return fmt.Errorf("refusing %q: it names a path outside the tree", h.Name)
		}
		target := filepath.Join(root, filepath.FromSlash(rel))
		switch h.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(target, 0o755); err != nil {
				return err
			}
		case tar.TypeReg:
			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
				return err
			}
			mode := os.FileMode(0o644)
			if h.FileInfo().Mode().Perm()&0o111 != 0 {
				mode = 0o755
			}
			f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
			if err != nil {
				return err
			}
			if _, err := io.Copy(f, tr); err != nil {
				f.Close()
				return err
			}
			if err := f.Close(); err != nil {
				return err
			}
		case tar.TypeSymlink:
			link := filepath.FromSlash(h.Linkname)
			if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(filepath.FromSlash(rel)), link)) {
				return fmt.Errorf("refusing link %q -> %q: it points outside the tree", h.Name, h.Linkname)
			}
			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
				return err
			}
			if err := os.Symlink(link, target); err != nil {
				return err
			}
		}
	}
}