quorate/services/project-delivery/controller/forgejo.go

380 lines
10 KiB
Go

package main
import (
"bufio"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
)
type sourceReader interface {
Refs(context.Context, string) (map[string]string, error)
PathIdentity(context.Context, string, string, []string) (string, error)
}
type forgejoRouter struct {
routes map[string]sourceReader
}
type forgejoRoute struct {
authority string
source sourceReader
}
type forgejoClient struct {
baseURL *url.URL
http *http.Client
authorization string
}
func newForgejoRouter(routes ...forgejoRoute) (*forgejoRouter, error) {
normalized := make(map[string]sourceReader, len(routes))
for _, route := range routes {
parsed, err := url.Parse("https://" + route.authority)
if err != nil || parsed.Host == "" || parsed.Host != route.authority || parsed.Path != "" || parsed.User != nil {
return nil, fmt.Errorf("invalid Forgejo repository authority %q", route.authority)
}
key := strings.ToLower(parsed.Host)
if route.source == nil {
return nil, fmt.Errorf("Forgejo repository authority %q has no route", route.authority)
}
if _, found := normalized[key]; found {
return nil, fmt.Errorf("duplicate Forgejo repository authority %q", route.authority)
}
normalized[key] = route.source
}
if len(normalized) == 0 {
return nil, errors.New("no Forgejo repository routes configured")
}
return &forgejoRouter{routes: normalized}, nil
}
func (router *forgejoRouter) Refs(ctx context.Context, repository string) (map[string]string, error) {
authority, err := repositoryAuthority(repository)
if err != nil {
return nil, err
}
source, found := router.routes[authority]
if !found {
return nil, fmt.Errorf("no Forgejo route for repository authority %q", authority)
}
return source.Refs(ctx, repository)
}
func (router *forgejoRouter) PathIdentity(
ctx context.Context,
repository, revision string,
paths []string,
) (string, error) {
authority, err := repositoryAuthority(repository)
if err != nil {
return "", err
}
source, found := router.routes[authority]
if !found {
return "", fmt.Errorf("no Forgejo route for repository authority %q", authority)
}
return source.PathIdentity(ctx, repository, revision, paths)
}
func newForgejoClient(base string, client *http.Client, token string) (*forgejoClient, error) {
parsed, err := url.Parse(base)
if err != nil {
return nil, fmt.Errorf("parse Forgejo base URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errors.New("forgejo base URL must use HTTP or HTTPS")
}
if parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("invalid Forgejo base URL")
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
authorization := ""
if token != "" {
authorization = "Basic " + base64.StdEncoding.EncodeToString([]byte("oauth2:"+token))
}
return &forgejoClient{baseURL: parsed, http: client, authorization: authorization}, nil
}
func repositoryParts(repository string) (string, string, error) {
parsed, err := url.Parse(repository)
if err != nil {
return "", "", err
}
parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.EscapedPath(), ".git"), "/"), "/")
if len(parts) != 2 {
return "", "", errors.New("repository URL must contain owner and repository")
}
owner, err := url.PathUnescape(parts[0])
if err != nil {
return "", "", err
}
name, err := url.PathUnescape(parts[1])
if err != nil {
return "", "", err
}
if owner == "" || name == "" || strings.ContainsAny(owner+name, "\x00/\\") {
return "", "", errors.New("invalid repository path")
}
return owner, name, nil
}
func repositoryPath(repository string) (string, error) {
owner, name, err := repositoryParts(repository)
if err != nil {
return "", err
}
return url.PathEscape(owner) + "/" + url.PathEscape(name) + ".git", nil
}
func repositoryAuthority(repository string) (string, error) {
parsed, err := url.Parse(repository)
if err != nil {
return "", err
}
if parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("repository URL must be an HTTPS URL without credentials, query, or fragment")
}
if _, err := repositoryPath(repository); err != nil {
return "", err
}
return strings.ToLower(parsed.Host), nil
}
func (client *forgejoClient) Refs(ctx context.Context, repository string) (map[string]string, error) {
path, err := repositoryPath(repository)
if err != nil {
return nil, err
}
endpoint := *client.baseURL
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + path + "/info/refs"
query := endpoint.Query()
query.Set("service", "git-upload-pack")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/x-git-upload-pack-advertisement")
if client.authorization != "" {
req.Header.Set("Authorization", client.authorization)
}
response, err := client.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return nil, fmt.Errorf("forgejo refs returned HTTP %d", response.StatusCode)
}
return parseAdvertisement(response.Body)
}
type treeEntry struct {
Path string `json:"path"`
Mode string `json:"mode"`
Type string `json:"type"`
SHA string `json:"sha"`
}
type treePage struct {
Entries []treeEntry `json:"tree"`
TotalCount int `json:"total_count"`
}
type pathEntry struct {
Found bool
Mode string
SHA string
}
func (client *forgejoClient) PathIdentity(
ctx context.Context,
repository, revision string,
paths []string,
) (string, error) {
owner, name, err := repositoryParts(repository)
if err != nil {
return "", err
}
ordered := append([]string(nil), paths...)
sort.Strings(ordered)
cache := map[string][]treeEntry{}
digest := sha256.New()
for _, path := range ordered {
entry, err := client.pathEntry(ctx, owner, name, revision, path, cache)
if err != nil {
return "", err
}
writeIdentity(digest, path)
if !entry.Found {
writeIdentity(digest, "missing")
continue
}
writeIdentity(digest, entry.Mode)
writeIdentity(digest, entry.SHA)
}
return "sha256:" + hex.EncodeToString(digest.Sum(nil)), nil
}
func (client *forgejoClient) pathEntry(
ctx context.Context,
owner, name, revision, path string,
cache map[string][]treeEntry,
) (pathEntry, error) {
tree := revision
parts := strings.Split(path, "/")
for index, part := range parts {
entries, found := cache[tree]
if !found {
var err error
entries, err = client.tree(ctx, owner, name, tree)
if err != nil {
return pathEntry{}, err
}
cache[tree] = entries
}
entry := treeEntry{}
for _, candidate := range entries {
if candidate.Path == part {
entry = candidate
break
}
}
if entry.Path == "" {
return pathEntry{}, nil
}
if index == len(parts)-1 {
return pathEntry{Found: true, Mode: entry.Mode, SHA: entry.SHA}, nil
}
if entry.Type != "tree" {
return pathEntry{}, nil
}
tree = entry.SHA
}
return pathEntry{}, nil
}
func (client *forgejoClient) tree(
ctx context.Context,
owner, name, revision string,
) ([]treeEntry, error) {
entries := []treeEntry{}
for page := 1; ; page++ {
endpoint := *client.baseURL
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/api/v1/repos/" +
url.PathEscape(owner) + "/" + url.PathEscape(name) + "/git/trees/" + url.PathEscape(revision)
query := endpoint.Query()
query.Set("page", strconv.Itoa(page))
query.Set("per_page", "100")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if client.authorization != "" {
req.Header.Set("Authorization", client.authorization)
}
response, err := client.http.Do(req)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
_ = response.Body.Close()
return nil, fmt.Errorf("forgejo tree returned HTTP %d", response.StatusCode)
}
var result treePage
err = json.NewDecoder(response.Body).Decode(&result)
_ = response.Body.Close()
if err != nil {
return nil, fmt.Errorf("decode Forgejo tree: %w", err)
}
entries = append(entries, result.Entries...)
if len(result.Entries) < 100 || result.TotalCount > 0 && len(entries) >= result.TotalCount {
return entries, nil
}
}
}
func writeIdentity(output io.Writer, value string) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(value)))
_, _ = output.Write(length[:])
_, _ = io.WriteString(output, value)
}
func parseAdvertisement(input io.Reader) (map[string]string, error) {
reader := bufio.NewReader(input)
refs := map[string]string{}
peeled := map[string]string{}
for {
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("read pkt-line header: %w", err)
}
length, err := strconv.ParseUint(string(header), 16, 16)
if err != nil {
return nil, errors.New("invalid pkt-line length")
}
if length == 0 || length == 1 || length == 2 {
continue
}
if length < 4 || length > 65520 {
return nil, errors.New("invalid pkt-line size")
}
payload := make([]byte, int(length)-4)
if _, err := io.ReadFull(reader, payload); err != nil {
return nil, fmt.Errorf("read pkt-line payload: %w", err)
}
line := strings.TrimSuffix(string(payload), "\n")
if strings.HasPrefix(line, "# service=") || line == "" {
continue
}
line = strings.SplitN(line, "\x00", 2)[0]
fields := strings.SplitN(line, " ", 2)
if len(fields) != 2 || !validObjectID(fields[0]) {
return nil, errors.New("invalid advertised ref")
}
name := strings.TrimSpace(fields[1])
if !strings.HasPrefix(name, "refs/") {
continue
}
if strings.HasSuffix(name, "^{}") {
peeled[strings.TrimSuffix(name, "^{}")] = fields[0]
continue
}
refs[name] = fields[0]
}
for name, revision := range peeled {
if _, found := refs[name]; found {
refs[name] = revision
}
}
return refs, nil
}
func validObjectID(value string) bool {
if len(value) != 40 && len(value) != 64 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded)*2 == len(value) && value == strings.ToLower(value)
}