package runner
import (
"archive/tar"
"compress/gzip"
"context"
"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, 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)
}
// 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
}
}
}
}