380 lines
10 KiB
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)
|
|
}
|