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
}
}
}
}