ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
380
services/project-delivery/controller/forgejo.go
Normal file
380
services/project-delivery/controller/forgejo.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Reference in a new issue