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) }