~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
307 lines · 7.7 KB Raw History
package guestimagerelease

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/guestimage"
)

const (
	MaxManifest   = 64 << 10
	ClientTimeout = 30 * time.Second
)

type Options struct {
	Base           string
	Target         string
	Version        string
	Commit         string
	ManifestSHA256 string
	Client         *http.Client
}

const (
	LatestRef     = "main/latest"
	ContentPrefix = "guest-images"
)

func ContentKey(target, manifestSHA256 string) string {
	return ContentPrefix + "/" + target + "/" + manifestSHA256
}

var (
	ErrNotPublished = errors.New("guestimagerelease: no published guest image")
	errUnreadable   = errors.New("guestimagerelease: a candidate could not be read")
	ErrPinMismatch  = errors.New("guestimagerelease: every published guest image differs from this build's pin")
)

func Source(ctx context.Context, o Options) (string, error) {
	if o.Base == "" {
		return "", errors.New("guestimagerelease: no base to download a guest image from")
	}
	if o.Target == "" {
		return "", errors.New("guestimagerelease: this host has no guest image target")
	}
	if o.ManifestSHA256 == "" {
		return "", errors.New("guestimagerelease: no pinned sha256 for the manifest, so a candidate cannot be matched against this build; pass the pin in versions.json")
	}
	base := strings.TrimSuffix(o.Base, "/")
	client := o.Client
	if client == nil {
		client = &http.Client{Timeout: ClientTimeout}
	}
	refs := make([]string, 0, 4)
	add := func(ref string) {
		if slices.Contains(refs, ref) {
			return
		}
		refs = append(refs, ref)
	}
	if o.Version != "" {
		add("releases/" + o.Version)
	}
	if o.Commit != "" {
		add("main/" + o.Commit)
	}
	add(LatestRef)
	tried := make([]string, 0, len(refs)+1)
	mismatched := make([]string, 0, len(refs)+1)
	unreadable := make([]string, 0, len(refs)+1)
	refSource := func(ref string) string {
		return base + "/" + ref + "/guest-image/" + o.Target
	}
	try := func(src string) (string, bool, error) {
		manifest := src + "/" + guestimage.ManifestName
		ok, err := published(ctx, client, manifest)
		if err != nil {
			if errors.Is(err, errUnreadable) {
				unreadable = append(unreadable, src)
				return src, false, nil
			}
			return "", false, err
		}
		if !ok {
			tried = append(tried, src)
			return src, false, nil
		}
		match, err := manifestMatches(ctx, client, manifest, o.ManifestSHA256)
		if err != nil {
			if errors.Is(err, errUnreadable) {
				unreadable = append(unreadable, src)
				return src, false, nil
			}
			return "", false, err
		}
		if !match {
			mismatched = append(mismatched, src)
			return src, false, nil
		}
		return src, true, nil
	}
	src, matched, err := try(base + "/" + ContentKey(o.Target, o.ManifestSHA256))
	if err != nil {
		return "", err
	}
	if matched {
		return src, nil
	}
	for _, ref := range refs {
		src, matched, err := try(refSource(ref))
		if err != nil {
			return "", err
		}
		if matched {
			return src, nil
		}
	}
	newest, listErr := newestRelease(ctx, client, base)
	if listErr == nil && newest != "" {
		if ref := "releases/" + newest; !slices.Contains(refs, ref) {
			src, matched, err := try(refSource(ref))
			if err != nil {
				return "", err
			}
			if matched {
				return src, nil
			}
		}
	}
	looked := ""
	if len(tried) > 0 {
		looked = ", tried " + strings.Join(tried, ", ")
	}
	unread := ""
	if len(unreadable) > 0 {
		unread = ", and these answered that they could not be read: " + strings.Join(unreadable, ", ")
	}
	absent := ""
	if len(tried) > 0 {
		absent = "; absent at " + strings.Join(tried, ", ")
	}
	unlisted := ""
	if listErr != nil {
		unlisted = fmt.Sprintf(", and the list of releases at %s could not be read: %v", base, listErr)
	}
	if len(mismatched) > 0 {
		return "", fmt.Errorf("%w (%s): published but different at %s%s%s%s", ErrPinMismatch, o.ManifestSHA256,
			strings.Join(mismatched, ", "), absent, unread, unlisted)
	}
	if len(unreadable) > 0 {
		return "", fmt.Errorf("%w for target %s%s%s%s", ErrNotPublished, o.Target, looked, unread, unlisted)
	}
	if listErr != nil {
		return "", fmt.Errorf("%w for target %s%s, and the list of releases at %s could not be read: %w",
			ErrNotPublished, o.Target, looked, base, listErr)
	}
	return "", fmt.Errorf("%w for target %s%s", ErrNotPublished, o.Target, looked)
}

func manifestMatches(ctx context.Context, client *http.Client, url, want string) (bool, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return false, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return false, fmt.Errorf("guestimagerelease: %s: %w", url, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return false, fmt.Errorf("%w: %s answered %s", errUnreadable, url, resp.Status)
	}
	h := sha256.New()
	if _, err := io.Copy(h, io.LimitReader(resp.Body, MaxManifest)); err != nil {
		return false, fmt.Errorf("guestimagerelease: reading %s: %w", url, err)
	}
	return hex.EncodeToString(h.Sum(nil)) == want, nil
}

func newestRelease(ctx context.Context, client *http.Client, base string) (string, error) {
	url := base + "/?list-type=2&prefix=releases/&delimiter=/"
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("guestimagerelease: %s: %w", url, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("guestimagerelease: %s answered %s", url, resp.Status)
	}
	var list struct {
		Common []struct {
			Prefix string `xml:"Prefix"`
		} `xml:"CommonPrefixes"`
	}
	if err := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&list); err != nil {
		return "", fmt.Errorf("guestimagerelease: reading the listing of %s: %w", base, err)
	}
	newest := ""
	for _, p := range list.Common {
		v := strings.TrimSuffix(strings.TrimPrefix(p.Prefix, "releases/"), "/")
		if !strings.HasPrefix(v, "v") {
			continue
		}
		if newest == "" || compareVersion(v, newest) > 0 {
			newest = v
		}
	}
	return newest, nil
}

func compareVersion(a, b string) int {
	an, ap := splitVersion(a)
	bn, bp := splitVersion(b)
	for i := range 3 {
		if an[i] != bn[i] {
			return sign(an[i] - bn[i])
		}
	}
	switch {
	case ap == "" && bp == "":
		return 0
	case ap == "":
		return 1
	case bp == "":
		return -1
	}
	pa, na := splitPre(ap)
	pb, nb := splitPre(bp)
	if pa != pb {
		if pa < pb {
			return -1
		}
		return 1
	}
	return sign(na - nb)
}

func splitVersion(v string) ([3]int, string) {
	v = strings.TrimPrefix(v, "v")
	pre := ""
	if i := strings.IndexAny(v, "-+"); i >= 0 {
		v, pre = v[:i], v[i+1:]
	}
	var out [3]int
	for i, part := range strings.SplitN(v, ".", 3) {
		n, err := strconv.Atoi(part)
		if err == nil {
			out[i] = n
		}
	}
	return out, pre
}

func splitPre(s string) (string, int) {
	i := 0
	for i < len(s) && (s[i] < '0' || s[i] > '9') {
		i++
	}
	n, _ := strconv.Atoi(s[i:])
	return s[:i], n
}

func sign(n int) int {
	switch {
	case n < 0:
		return -1
	case n > 0:
		return 1
	}
	return 0
}

func published(ctx context.Context, client *http.Client, url string) (bool, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
	if err != nil {
		return false, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return false, fmt.Errorf("guestimagerelease: %s: %w", url, err)
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)
	switch resp.StatusCode {
	case http.StatusOK:
		return true, nil
	case http.StatusNotFound:
		return false, nil
	case http.StatusForbidden:
		return false, fmt.Errorf("%w: %s answered %s, so the object may be present but unreadable", errUnreadable, url, resp.Status)
	}
	return false, fmt.Errorf("%w: %s answered %s", errUnreadable, url, resp.Status)
}