Add vcs remote type for git hosting archives and release assets

This commit is contained in:
2026-10-24 16:31:02 +11:00
parent 7fa5434dbc
commit 48c127e5a0
10 changed files with 565 additions and 1 deletions
+1
View File
@@ -41,6 +41,7 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/<
- `pypi`: [remote](docs/pypi/remote.md)
- `rpm`: [remote](docs/rpm/remote.md), [remote-github](docs/rpm/remote-github.md)
- `terraform`: [remote](docs/terraform/remote.md), [local](docs/terraform/local.md)
- `vcs`: [remote](docs/vcs/remote.md)
### GitHub authentication
+1 -1
View File
@@ -98,4 +98,4 @@ Each type's page lists its auto-detected mutable and immutable paths:
[docker](../docker/remote.md), [generic](../generic/remote.md), [gems](../gems/remote.md), [goproxy](../goproxy/remote.md),
[helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md),
[puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/remote.md),
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md).
[rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md), [vcs](../vcs/remote.md).
+29
View File
@@ -0,0 +1,29 @@
# `vcs` remote
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
Caches source archives and release assets from GitHub (`base_url = "https://github.com"`) or any Gitea (any other `base_url`). Only the paths below are served; anything else is a 404.
| Path | Behaviour |
|---|---|
| `<owner>/<repo>/archive/refs/tags/<tag>.(tar.gz\|zip)` | immutable |
| `<owner>/<repo>/archive/<40-char sha>.(tar.gz\|zip)` | immutable |
| `<owner>/<repo>/releases/download/<tag>/<asset>` | immutable |
| `<owner>/<repo>/releases/latest/download/<asset>` | mutable |
| `<owner>/<repo>/archive/refs/heads/<branch>.(tar.gz\|zip)` | `302` to the archive of the branch's current commit, resolved via the provider API and reused for `mutable_ttl` |
Branch archives redirect, so `curl` needs `-L`; pip, npm and go follow redirects. The archive's top-level directory is `<repo>-<sha>`. `patterns` and `blocklist` match both the branch path and the commit path it redirects to, so allowlist repos by prefix (`^cli/cli/`). GitHub API calls use the [server GitHub credential](../../README.md#github-authentication); Gitea API calls use the remote's `username`/`password`.
```hcl
resource "artifactapi_remote_vcs" "github" {
name = "github-vcs"
base_url = "https://github.com"
mutable_ttl = 600
patterns = ["^cli/cli/", "^psf/requests/"]
}
```
```bash
curl -fsSLO https://artifactapi.example/api/v1/remote/github-vcs/cli/cli/archive/refs/tags/v2.60.0.tar.gz
pip install https://artifactapi.example/api/v1/remote/github-vcs/psf/requests/archive/refs/tags/v2.32.3.tar.gz
```
+7
View File
@@ -0,0 +1,7 @@
//go:build dockere2e
package e2edocker
func init() {
cachingCases = append(cachingCases, cachingCase{"vcs", "octo/repo/archive/refs/tags/v1.0.0.tar.gz", "octo/repo/archive/refs/tags/v1.0.0.tar.gz"})
}
+3
View File
@@ -0,0 +1,3 @@
package all
import _ "git.unkin.net/unkin/artifactapi/internal/provider/vcs"
+263
View File
@@ -0,0 +1,263 @@
// Package vcs caches source archives and release assets from GitHub or Gitea,
// keyed by ref: tags, commits and release assets are immutable, and branch
// archives redirect to the archive of the commit the branch currently points at.
package vcs
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
"git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(New())
}
type kind int
const (
tagArchive kind = iota + 1
branchArchive
commitArchive
releaseAsset
latestReleaseAsset
)
// target is a parsed vcs request path.
type target struct {
Kind kind
Owner, Repo string
Name string // tag, branch, commit or asset
Ext string
}
var (
segmentRe = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`)
commitRe = regexp.MustCompile(`^[0-9a-f]{40}$`)
)
var errNotFound = errors.New("ref not found")
// Provider embeds generic for upstream URLs, content types and GitHub/Basic auth.
type Provider struct {
generic.Provider
client *http.Client
githubAPI string
mu sync.Mutex
refs map[string]resolved
}
type resolved struct {
commit string
at time.Time
}
func New() *Provider {
return &Provider{
client: &http.Client{Timeout: 30 * time.Second},
githubAPI: "https://api.github.com",
refs: map[string]resolved{},
}
}
func (p *Provider) Type() models.PackageType { return models.PackageVCS }
func (p *Provider) Classify(path string) provider.Mutability {
if ref, ok := parsePath(path); ok && (ref.Kind == branchArchive || ref.Kind == latestReleaseAsset) {
return provider.Mutable
}
return provider.Immutable
}
// parsePath accepts only the archive and release-download shapes, rejecting
// any segment that could escape owner/repo or a ref.
func parsePath(path string) (target, bool) {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || !validSegment(parts[0]) || !validSegment(parts[1]) {
return target{}, false
}
ref := target{Owner: parts[0], Repo: parts[1]}
rest := parts[2]
switch {
case strings.HasPrefix(rest, "archive/"):
name, ext, ok := archiveName(strings.TrimPrefix(rest, "archive/"))
if !ok {
return target{}, false
}
ref.Ext = ext
switch {
case strings.HasPrefix(name, "refs/tags/"):
ref.Kind, ref.Name = tagArchive, strings.TrimPrefix(name, "refs/tags/")
case strings.HasPrefix(name, "refs/heads/"):
ref.Kind, ref.Name = branchArchive, strings.TrimPrefix(name, "refs/heads/")
case commitRe.MatchString(name):
return target{Kind: commitArchive, Owner: ref.Owner, Repo: ref.Repo, Name: name, Ext: ext}, true
default:
return target{}, false
}
return ref, validRefName(ref.Name)
case strings.HasPrefix(rest, "releases/latest/download/"):
ref.Kind, ref.Name = latestReleaseAsset, strings.TrimPrefix(rest, "releases/latest/download/")
return ref, validSegment(ref.Name)
case strings.HasPrefix(rest, "releases/download/"):
tagAsset := strings.TrimPrefix(rest, "releases/download/")
i := strings.LastIndex(tagAsset, "/")
if i < 0 {
return target{}, false
}
ref.Kind, ref.Name = releaseAsset, tagAsset[i+1:]
return ref, validRefName(tagAsset[:i]) && validSegment(ref.Name)
}
return target{}, false
}
func archiveName(s string) (string, string, bool) {
for _, ext := range []string{".tar.gz", ".zip"} {
if name, ok := strings.CutSuffix(s, ext); ok && name != "" {
return name, ext, true
}
}
return "", "", false
}
func validSegment(s string) bool {
return segmentRe.MatchString(s) && s != "." && s != ".." && !strings.Contains(s, "..")
}
func validRefName(s string) bool {
for _, seg := range strings.Split(s, "/") {
if !validSegment(seg) || strings.HasPrefix(seg, ".") {
return false
}
}
return true
}
// ServeRemote rejects unsupported paths and redirects branch archives to the
// immutable archive of the branch's current commit; everything else is proxied.
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
ref, ok := parsePath(path)
if !ok {
http.Error(w, "unsupported vcs path", http.StatusNotFound)
return true
}
if ref.Kind != branchArchive {
return false
}
if proxy.NewClassifier(p).Classify(remote, path) == proxy.ClassDenied {
http.Error(w, "access denied", http.StatusForbidden)
return true
}
commit, err := p.resolve(r.Context(), remote, ref)
if errors.Is(err, errNotFound) {
http.Error(w, "branch not found", http.StatusNotFound)
return true
}
if err != nil {
http.Error(w, "bad gateway", http.StatusBadGateway)
return true
}
loc := strings.TrimRight(proxyBaseURL, "/") + provider.RemoteMountPath + remote.Name + "/" +
ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
http.Redirect(w, r, loc, http.StatusFound)
return true
}
// resolve maps a branch to its commit, reusing the answer for mutable_ttl and,
// with stale_on_error, past an upstream failure.
func (p *Provider) resolve(ctx context.Context, remote models.Remote, ref target) (string, error) {
key := remote.Name + "\x00" + ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
p.mu.Lock()
prev, ok := p.refs[key]
p.mu.Unlock()
if ok && time.Since(prev.at) < time.Duration(remote.MutableTTL)*time.Second {
return prev.commit, nil
}
commit, err := p.lookup(ctx, remote, ref)
if err != nil {
if ok && remote.StaleOnError && !errors.Is(err, errNotFound) {
return prev.commit, nil
}
return "", err
}
p.mu.Lock()
p.refs[key] = resolved{commit: commit, at: time.Now()}
p.mu.Unlock()
return commit, nil
}
func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target) (string, error) {
segs := strings.Split(ref.Name, "/")
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
branch := strings.Join(segs, "/")
github := isGitHub(remote.BaseURL)
api := strings.TrimRight(remote.BaseURL, "/") + "/api/v1/repos/" + ref.Owner + "/" + ref.Repo + "/branches/" + branch
if github {
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/commits/" + branch
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
if err != nil {
return "", err
}
h, err := p.AuthHeaders(ctx, remote)
if err != nil {
return "", err
}
req.Header = h
if github {
req.Header.Set("Accept", "application/vnd.github.sha")
}
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
return "", errNotFound
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("branch lookup %s: %s", api, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
commit := strings.TrimSpace(string(body))
if !github {
var b struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &b); err != nil {
return "", err
}
commit = b.Commit.ID
}
if !commitRe.MatchString(commit) {
return "", fmt.Errorf("branch lookup %s: unexpected commit %q", api, commit)
}
return commit, nil
}
func isGitHub(baseURL string) bool {
u, err := url.Parse(baseURL)
return err == nil && strings.EqualFold(u.Hostname(), "github.com")
}
+230
View File
@@ -0,0 +1,230 @@
package vcs
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const (
shaA = "4f2cfe92e3499f8581bf39db77e0a1431fa7b55c"
shaB = "7fa5434dbc083c853eef435d1915020345219d2f"
)
func TestParsePath(t *testing.T) {
tests := []struct {
path string
ok bool
kind kind
name string
}{
{"cli/cli/archive/refs/tags/v2.60.0.tar.gz", true, tagArchive, "v2.60.0"},
{"/cli/cli/archive/refs/tags/v2.60.0.zip", true, tagArchive, "v2.60.0"},
{"o/r/archive/refs/tags/release/1.0.tar.gz", true, tagArchive, "release/1.0"},
{"o/r/archive/refs/heads/feature/x.tar.gz", true, branchArchive, "feature/x"},
{"o/r/archive/" + shaA + ".tar.gz", true, commitArchive, shaA},
{"o/r/releases/download/v1.0/tool_linux_amd64.tar.gz", true, releaseAsset, "tool_linux_amd64.tar.gz"},
{"o/r/releases/latest/download/tool.zip", true, latestReleaseAsset, "tool.zip"},
{"o/r/archive/main.tar.gz", false, 0, ""},
{"o/r/archive/" + shaA[:7] + ".tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1.tar.bz2", false, 0, ""},
{"o/r/archive/refs/tags/.tar.gz", false, 0, ""},
{"../r/archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/../archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/../../../etc/passwd.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a//b.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/.hidden.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a..b.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1%2f.tar.gz", false, 0, ""},
{"o/r/releases/download/v1/../x", false, 0, ""},
{"o/r/releases/download/asset", false, 0, ""},
{"o/r/raw/main/README.md", false, 0, ""},
{"o/r", false, 0, ""},
}
for _, tt := range tests {
ref, ok := parsePath(tt.path)
if ok != tt.ok || (ok && (ref.Kind != tt.kind || ref.Name != tt.name)) {
t.Errorf("parsePath(%q) = %+v, %v; want kind %v name %q ok %v", tt.path, ref, ok, tt.kind, tt.name, tt.ok)
}
}
}
func TestClassify(t *testing.T) {
p := New()
for path, want := range map[string]provider.Mutability{
"o/r/archive/refs/tags/v1.tar.gz": provider.Immutable,
"o/r/archive/" + shaA + ".zip": provider.Immutable,
"o/r/releases/download/v1/a.bin": provider.Immutable,
"o/r/archive/refs/heads/main.tar.gz": provider.Mutable,
"o/r/releases/latest/download/webui.zip": provider.Mutable,
} {
if got := p.Classify(path); got != want {
t.Errorf("Classify(%q) = %v, want %v", path, got, want)
}
}
}
type upstream struct {
srv *httptest.Server
hits atomic.Int32
commit atomic.Value
status atomic.Int32
accept atomic.Value
path atomic.Value
}
func newUpstream(t *testing.T, gitea bool) *upstream {
u := &upstream{}
u.commit.Store(shaA)
u.status.Store(http.StatusOK)
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits.Add(1)
u.accept.Store(r.Header.Get("Accept"))
u.path.Store(r.URL.EscapedPath())
if s := int(u.status.Load()); s != http.StatusOK {
w.WriteHeader(s)
return
}
if gitea {
w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
return
}
w.Write([]byte(u.commit.Load().(string)))
}))
t.Cleanup(u.srv.Close)
return u
}
func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRecorder, bool) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/"+remote.Name+"/"+path, nil)
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
}
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600}
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
if !handled || w.Code != http.StatusFound {
t.Fatalf("handled=%v code=%d", handled, w.Code)
}
if got, want := w.Header().Get("Location"), "http://proxy/api/v1/remote/gitea/unkin/app/archive/"+shaA+".zip"; got != want {
t.Errorf("Location = %q, want %q", got, want)
}
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
t.Errorf("api path = %q", got)
}
}
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
u := newUpstream(t, false)
p := New()
p.githubAPI = u.srv.URL
remote := models.Remote{Name: "gh", BaseURL: "https://github.com", MutableTTL: 3600}
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/trunk.tar.gz")
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
t.Fatalf("Location = %q", w.Header().Get("Location"))
}
if u.path.Load() != "/repos/cli/cli/commits/trunk" || u.accept.Load() != "application/vnd.github.sha" {
t.Errorf("api path %q accept %q", u.path.Load(), u.accept.Load())
}
}
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
u := newUpstream(t, true)
p := New()
cached := models.Remote{Name: "cached", BaseURL: u.srv.URL, MutableTTL: 3600}
path := "o/r/archive/refs/heads/main.tar.gz"
serve(p, cached, path)
u.commit.Store(shaB)
w, _ := serve(p, cached, path)
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
expired := cached
expired.Name = "expired"
expired.MutableTTL = 0
serve(p, expired, path)
w, _ = serve(p, expired, path)
if u.hits.Load() != 3 || !strings.Contains(w.Header().Get("Location"), shaB) {
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
}
func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
path := "o/r/archive/refs/heads/main.tar.gz"
for _, tc := range []struct {
name string
stale bool
want int
}{
{"stale", true, http.StatusFound},
{"strict", false, http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: tc.name, BaseURL: u.srv.URL, StaleOnError: tc.stale}
serve(p, remote, path)
u.status.Store(http.StatusInternalServerError)
if w, _ := serve(p, remote, path); w.Code != tc.want {
t.Fatalf("code = %d, want %d", w.Code, tc.want)
}
})
}
}
func TestServeRemoteBranchNotFound(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(http.StatusUnprocessableEntity)
p := New()
p.githubAPI = u.srv.URL
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/nope.tar.gz")
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
func TestServeRemoteRejectsBadCommit(t *testing.T) {
u := newUpstream(t, true)
u.commit.Store("../../etc")
w, _ := serve(New(), models.Remote{Name: "g", BaseURL: u.srv.URL}, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != http.StatusBadGateway {
t.Fatalf("code = %d, want 502", w.Code)
}
}
func TestServeRemoteAllowlist(t *testing.T) {
u := newUpstream(t, true)
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Patterns: []string{"^allowed/repo/"}}
if w, _ := serve(New(), remote, "other/repo/archive/refs/heads/main.tar.gz"); w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if u.hits.Load() != 0 {
t.Fatalf("denied branch must not hit the API")
}
}
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
p := New()
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}
if _, handled := serve(p, remote, "o/r/archive/refs/tags/v1.tar.gz"); handled {
t.Error("tag archives must go through the proxy engine")
}
if _, handled := serve(p, remote, "o/r/releases/download/v1/a.bin"); handled {
t.Error("release assets must go through the proxy engine")
}
if w, handled := serve(p, remote, "o/r/raw/main/x"); !handled || w.Code != http.StatusNotFound {
t.Errorf("unsupported path: handled=%v code=%d", handled, w.Code)
}
}
+5
View File
@@ -0,0 +1,5 @@
package models
const PackageVCS PackageType = "vcs"
func init() { registerPackageType(PackageVCS) }
+26
View File
@@ -0,0 +1,26 @@
import type { PackageTypeUI } from '../types';
const def: PackageTypeUI = {
color: 'default',
snippets: ({ proxy }) => {
return [
{
title: 'Download a tag archive',
language: 'bash',
code: `curl -fsSLO ${proxy}/<owner>/<repo>/archive/refs/tags/<tag>.tar.gz`,
},
{
title: 'Download a branch archive (redirects to its current commit)',
language: 'bash',
code: `curl -fsSL -o src.tar.gz ${proxy}/<owner>/<repo>/archive/refs/heads/<branch>.tar.gz`,
},
{
title: 'Download a release asset',
language: 'bash',
code: `curl -fsSLO ${proxy}/<owner>/<repo>/releases/download/<tag>/<asset>`,
},
];
},
};
export default def;