fix(workspace-server/plugins): fast-fail gitea:// private repo installs via archive API #3153

Merged
devops-engineer merged 1 commits from feat/gitea-private-repo-fastfail into main 2026-06-22 04:45:42 +00:00
2 changed files with 651 additions and 35 deletions
+325 -35
View File
@@ -1,21 +1,26 @@
package plugins
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// GiteaResolver fetches plugins from a (typically private) Gitea repository
// by shallow-cloning at the specified ref and extracting an optional
// subpath. It exists so a declared plugin can resolve to a *subdirectory*
// of a larger repo — e.g. the `agent-skills/seo-all/` skill package inside
// the private seo-agent template repo — which the GitHub resolver cannot do
// (it copies the whole repo root).
// GiteaResolver fetches plugins from a (typically private) Gitea repository.
// It uses the Gitea archive API for HTTP(S) remotes and falls back to a
// shallow git clone only for local file:// test repos. The archive path is
// fast-fail: private or missing repos return a clear error within seconds
// instead of hanging on a credential prompt.
//
// Source-contract string (the value a template puts in `plugins:`):
//
@@ -34,15 +39,26 @@ import (
// Authentication: private Gitea repos need a PAT. The resolver reads the
// token from the environment (MOLECULE_TEMPLATE_REPO_TOKEN by default — the
// same read-only Gitea PAT CP PR#850 already places on every tenant box).
// The token is injected into the clone URL's userinfo and never logged.
// The token is sent in an Authorization header for API calls and is never
// logged or returned to clients.
//
// Pinned-ref enforcement mirrors GithubResolver: an unpinned spec (no
// `#<ref>`) is rejected unless PLUGIN_ALLOW_UNPINNED=true.
type GiteaResolver struct {
// GitRunner runs git commands. Defaults to defaultGitRunner (shells out
// to the system `git`). Overridable in tests.
// GitRunner runs git commands for file:// / local test remotes.
// Defaults to defaultGitRunner. Unused for HTTP(S) archive fetches.
GitRunner func(ctx context.Context, dir string, args ...string) error
// ArchiveDownloader downloads and extracts a Gitea archive tarball for
// HTTP(S) remotes. Defaults to defaultArchiveDownloader. Overridable in
// tests to simulate private-repo 401/403/404 responses.
ArchiveDownloader func(ctx context.Context, archiveURL, token, dstDir string) error
// ResolveRefClient optionally overrides the HTTP client used by
// ResolveRef to fetch the commit SHA via the Gitea API. Defaults to
// http.DefaultClient. Overridable in tests.
ResolveRefClient *http.Client
// BaseURL is the Gitea instance origin, e.g. "https://git.moleculesai.app".
// Tests point it at a local file:// bare repo (in which case TokenEnv is
// ignored — file:// has no userinfo auth).
@@ -51,9 +67,13 @@ type GiteaResolver struct {
// TokenEnv is the environment variable the PAT is read from at Fetch
// time. Read lazily (not at construction) so a token rotated into the
// process env after startup is picked up. Empty disables auth injection
// (anonymous clone — works for public repos and file:// test repos).
// (anonymous API calls — works for public repos; fails fast on private).
TokenEnv string
// FetchTimeout bounds the archive download + SHA resolution for HTTP(S)
// remotes. Defaults to 30 seconds. Overridable in tests.
FetchTimeout time.Duration
// LastFetchSHA holds the commit SHA checked out by the last successful
// Fetch. Mirrors GithubResolver so the install pipeline's drift-seed
// type-switch can pick it up. Reset on each Fetch.
@@ -68,9 +88,10 @@ func NewGiteaResolver() *GiteaResolver {
base = "https://git.moleculesai.app"
}
return &GiteaResolver{
GitRunner: defaultGitRunner,
BaseURL: base,
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
GitRunner: defaultGitRunner,
ArchiveDownloader: defaultArchiveDownloader,
BaseURL: base,
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
}
}
@@ -169,7 +190,130 @@ func (r *GiteaResolver) cloneURL(owner, repo string) (string, error) {
return u.String(), nil
}
// Fetch clones the repo at the pinned ref and copies the (optional) subpath
// archiveRef returns a ref string suitable for the Gitea archive API.
// It strips the "tag:" and "sha:" prefixes used in plugin specs.
func archiveRef(ref string) string {
switch {
case strings.HasPrefix(ref, "tag:"):
return strings.TrimPrefix(ref, "tag:")
case strings.HasPrefix(ref, "sha:"):
return strings.TrimPrefix(ref, "sha:")
}
return ref
}
// defaultArchiveDownloader downloads a Gitea archive tarball to dstDir and
// extracts it. It is fail-closed and token-safe: the token is sent in the
// Authorization header, never logged, and never surfaced in error messages.
func defaultArchiveDownloader(ctx context.Context, archiveURL, token, dstDir string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return fmt.Errorf("gitea resolver: build archive request: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("gitea resolver: archive download timed out")
}
return fmt.Errorf("gitea resolver: archive download failed: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// proceed
case http.StatusNotFound:
return fmt.Errorf("gitea resolver: %s: %w", archiveURL, ErrPluginNotFound)
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("gitea resolver: %s: repository not accessible (HTTP %d)", archiveURL, resp.StatusCode)
default:
return fmt.Errorf("gitea resolver: %s: unexpected HTTP %d", archiveURL, resp.StatusCode)
}
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("gitea resolver: gzip archive: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("gitea resolver: read tar archive: %w", err)
}
// Clean the header name and reject traversal / absolute paths.
rel := filepath.Clean(hdr.Name)
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
continue
}
target := filepath.Join(dstDir, rel)
cleanTarget, err := filepath.Abs(target)
if err != nil {
return fmt.Errorf("gitea resolver: abs target: %w", err)
}
cleanDst, err := filepath.Abs(dstDir)
if err != nil {
return fmt.Errorf("gitea resolver: abs dst: %w", err)
}
if !strings.HasPrefix(cleanTarget, cleanDst+string(filepath.Separator)) {
continue // tar entry escapes extraction root — skip
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(hdr.Mode&0o777)); err != nil {
return fmt.Errorf("gitea resolver: mkdir %s: %w", target, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("gitea resolver: mkdir %s: %w", filepath.Dir(target), err)
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode&0o777))
if err != nil {
return fmt.Errorf("gitea resolver: create %s: %w", target, err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return fmt.Errorf("gitea resolver: write %s: %w", target, err)
}
f.Close()
default:
// Skip symlinks, devices, etc. copyTree also skips symlinks.
}
}
return nil
}
// repoRootFromArchive picks the single top-level directory inside an
// extracted Gitea archive. Gitea archives contain exactly one root directory
// named after the repo.
func repoRootFromArchive(archiveDir string) (string, error) {
entries, err := os.ReadDir(archiveDir)
if err != nil {
return "", fmt.Errorf("gitea resolver: read archive dir: %w", err)
}
var dirs []os.DirEntry
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e)
}
}
if len(dirs) != 1 {
return "", fmt.Errorf("gitea resolver: expected exactly one root dir in archive, found %d", len(dirs))
}
return filepath.Join(archiveDir, dirs[0].Name()), nil
}
// Fetch resolves the repo at the pinned ref and copies the (optional) subpath
// into dst. Returns the resolved plugin name.
func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (string, error) {
p, err := parseGiteaSpec(spec)
@@ -183,6 +327,22 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str
spec, p.owner, p.repo)
}
base := r.BaseURL
if base == "" {
base = "https://git.moleculesai.app"
}
// Local file:// remotes (tests) keep the git-clone path.
if strings.HasPrefix(base, "file://") || strings.HasPrefix(base, "/") {
return r.fetchGit(ctx, p, dst)
}
return r.fetchArchive(ctx, p, dst, base)
}
// fetchGit is the legacy local-file:// path used only by tests. It preserves
// the original shallow-clone behavior so real-git test fixtures keep working.
func (r *GiteaResolver) fetchGit(ctx context.Context, p parsedGiteaSpec, dst string) (string, error) {
runner := r.GitRunner
if runner == nil {
runner = defaultGitRunner
@@ -206,11 +366,6 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str
}
args = append(args, "--", cloneURL, cloneTarget)
if err := runner(ctx, workDir, args...); err != nil {
// Map "repo/ref doesn't exist" to ErrPluginNotFound (handler → 404).
// NOTE: the error string may contain the tokenized URL; callers MUST
// NOT surface resolver errors to clients (the install pipeline logs
// them server-side and returns a sanitized body). Errors are wrapped
// with a non-tokenized URL form for the log line.
safeURL := fmt.Sprintf("%s/%s/%s.git", r.BaseURL, p.owner, p.repo)
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "repository not found") ||
@@ -222,17 +377,83 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str
return "", fmt.Errorf("gitea resolver: clone %s failed: %w", safeURL, err)
}
// Capture the installed SHA before stripping .git (drift-seed parity).
if shaOut, shaErr := runGitOneLine(ctx, cloneTarget, "rev-parse", "--verify", "HEAD"); shaErr == nil {
r.LastFetchSHA = strings.TrimSpace(shaOut)
}
// The source tree to copy: repo root, or the subpath within it.
return r.stageTree(ctx, p, cloneTarget, dst)
}
// fetchArchive downloads the repo via the authenticated Gitea archive API,
// extracts it, and stages the requested subpath. It is bounded by a strict
// timeout and maps 401/403/404 to clear, token-safe errors.
func (r *GiteaResolver) fetchArchive(ctx context.Context, p parsedGiteaSpec, dst, base string) (string, error) {
token := ""
if r.TokenEnv != "" {
token = strings.TrimSpace(os.Getenv(r.TokenEnv))
}
u, err := url.Parse(base)
if err != nil {
return "", fmt.Errorf("gitea resolver: invalid base url: %w", err)
}
ref := archiveRef(p.ref)
u.Path = fmt.Sprintf("/api/v1/repos/%s/%s/archive/%s.tar.gz", p.owner, p.repo, ref)
archiveURL := u.String()
workDir, err := os.MkdirTemp("", "molecule-gitea-archive-*")
if err != nil {
return "", fmt.Errorf("gitea resolver: tempdir: %w", err)
}
defer os.RemoveAll(workDir)
archiveDir := filepath.Join(workDir, "extracted")
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
return "", fmt.Errorf("gitea resolver: mkdir archive dir: %w", err)
}
downloader := r.ArchiveDownloader
if downloader == nil {
downloader = defaultArchiveDownloader
}
// Bounded timeout: a private or unreachable repo must fail fast instead
// of hanging the install request until the gateway gives up (~100 s → 502).
timeout := r.FetchTimeout
if timeout <= 0 {
timeout = 30 * time.Second
}
fetchCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := downloader(fetchCtx, archiveURL, token, archiveDir); err != nil {
return "", err
}
cloneTarget, err := repoRootFromArchive(archiveDir)
if err != nil {
return "", err
}
// Resolve the installed SHA via the Gitea API. Same timeout so a missing
// or private repo fails fast here too.
sha, err := r.resolveSHA(fetchCtx, p.owner, p.repo, ref, token, base)
if err != nil {
return "", err
}
if sha != "" {
r.LastFetchSHA = sha
}
return r.stageTree(ctx, p, cloneTarget, dst)
}
// stageTree copies the repo root (or subpath) into dst and derives the plugin
// name. Shared by fetchGit and fetchArchive.
func (r *GiteaResolver) stageTree(ctx context.Context, p parsedGiteaSpec, cloneTarget, dst string) (string, error) {
srcTree := cloneTarget
pluginName := p.repo
if p.subpath != "" {
// filepath.Join cleans the path; reject any attempt to escape the
// clone (defence in depth — parseGiteaSpec already rejected "..").
joined := filepath.Join(cloneTarget, filepath.FromSlash(p.subpath))
relCheck, relErr := filepath.Rel(cloneTarget, joined)
if relErr != nil || strings.HasPrefix(relCheck, "..") {
@@ -250,11 +471,9 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str
return "", fmt.Errorf("gitea resolver: subpath %q is not a directory", p.subpath)
}
srcTree = joined
// Plugin name is the last subpath segment.
parts := strings.Split(p.subpath, "/")
pluginName = parts[len(parts)-1]
} else {
// Whole-repo install: strip .git so the plugin dir isn't a nested repo.
if err := os.RemoveAll(filepath.Join(cloneTarget, ".git")); err != nil {
return "", fmt.Errorf("gitea resolver: remove .git: %w", err)
}
@@ -267,6 +486,63 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str
return pluginName, nil
}
// resolveSHA fetches the commit SHA for a ref via the Gitea API. It is used
// by both Fetch (to populate LastFetchSHA) and ResolveRef. The ref argument
// must already be archiveRef-normalized.
func (r *GiteaResolver) resolveSHA(ctx context.Context, owner, repo, ref, token, base string) (string, error) {
u, err := url.Parse(base)
if err != nil {
return "", fmt.Errorf("gitea resolver: invalid base url: %w", err)
}
u.Path = fmt.Sprintf("/api/v1/repos/%s/%s/commits", owner, repo)
q := u.Query()
q.Set("sha", ref)
q.Set("limit", "1")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", fmt.Errorf("gitea resolver: build commits request: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := r.ResolveRefClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("gitea resolver: resolve SHA timed out")
}
return "", fmt.Errorf("gitea resolver: resolve SHA request failed: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return "", fmt.Errorf("gitea resolver: %s/%s ref %q: %w", owner, repo, ref, ErrPluginNotFound)
case http.StatusUnauthorized, http.StatusForbidden:
return "", fmt.Errorf("gitea resolver: %s/%s ref %q: not accessible (HTTP %d)", owner, repo, ref, resp.StatusCode)
default:
return "", fmt.Errorf("gitea resolver: %s/%s ref %q: unexpected HTTP %d", owner, repo, ref, resp.StatusCode)
}
var commits []struct {
SHA string `json:"sha"`
}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
return "", fmt.Errorf("gitea resolver: decode commits: %w", err)
}
if len(commits) == 0 || commits[0].SHA == "" {
return "", fmt.Errorf("gitea resolver: %s/%s ref %q: no commit returned", owner, repo, ref)
}
return strings.TrimSpace(commits[0].SHA), nil
}
// ResolveRef resolves a gitea spec's ref to a full commit SHA, so the drift
// sweeper can compare installed vs upstream for gitea:// sources too.
//
@@ -284,6 +560,30 @@ func (r *GiteaResolver) ResolveRef(ctx context.Context, spec string) (string, er
return "", fmt.Errorf("gitea resolver: ResolveRef requires a ref (got bare %q)", spec)
}
base := r.BaseURL
if base == "" {
base = "https://git.moleculesai.app"
}
ref := archiveRef(p.ref)
// Local file:// remotes (tests) keep the git-fetch path.
if strings.HasPrefix(base, "file://") || strings.HasPrefix(base, "/") {
return r.resolveRefGit(ctx, p, ref)
}
token := ""
if r.TokenEnv != "" {
token = strings.TrimSpace(os.Getenv(r.TokenEnv))
}
resolveCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return r.resolveSHA(resolveCtx, p.owner, p.repo, ref, token, base)
}
// resolveRefGit is the legacy local-file:// path used only by tests.
func (r *GiteaResolver) resolveRefGit(ctx context.Context, p parsedGiteaSpec, fetchRef string) (string, error) {
runner := r.GitRunner
if runner == nil {
runner = defaultGitRunner
@@ -299,16 +599,6 @@ func (r *GiteaResolver) ResolveRef(ctx context.Context, spec string) (string, er
}
defer os.RemoveAll(workDir)
// Normalize the ref into a fetchable git ref.
fetchRef := p.ref
switch {
case strings.HasPrefix(p.ref, "tag:"):
fetchRef = strings.TrimPrefix(p.ref, "tag:")
case strings.HasPrefix(p.ref, "sha:"):
fetchRef = strings.TrimPrefix(p.ref, "sha:")
}
// `git -C <workDir> init` then fetch the single ref shallowly.
if err := runner(ctx, workDir, "init", "-q"); err != nil {
return "", fmt.Errorf("gitea resolver: git init: %w", err)
}
@@ -1,13 +1,21 @@
package plugins
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestGiteaResolver_Scheme(t *testing.T) {
@@ -316,3 +324,321 @@ func TestGiteaResolver_RegisteredScheme(t *testing.T) {
t.Errorf("gitea scheme must resolve: %v", err)
}
}
// makePluginTarball returns a gzip-compressed tar archive containing a repo
// root directory named after repo, with files under the given relPaths.
func makePluginTarball(t *testing.T, repo string, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
for rel, content := range files {
name := repo + "/" + rel
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if _, err := io.WriteString(tw, content); err != nil {
t.Fatalf("write body: %v", err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gw.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// writeTarball writes a tarball to dstDir, simulating defaultArchiveDownloader.
func writeTarball(t *testing.T, dstDir string, data []byte) {
t.Helper()
gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
target := filepath.Join(dstDir, hdr.Name)
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("mkdir parent: %v", err)
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
t.Fatalf("create file: %v", err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
t.Fatalf("copy: %v", err)
}
f.Close()
}
}
func TestGiteaResolver_ArchiveFetch_PrivateRepo_FastFail(t *testing.T) {
const tok = "SUPERSECRET-ARCHIVE-TOKEN-999"
t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", tok)
for _, code := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} {
t.Run(fmt.Sprintf("HTTP%d", code), func(t *testing.T) {
r := &GiteaResolver{
BaseURL: "https://git.example.com",
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error {
if token != tok {
t.Errorf("token not passed to downloader: got %q", token)
}
switch code {
case http.StatusNotFound:
return fmt.Errorf("gitea resolver: %s: %w", archiveURL, ErrPluginNotFound)
default:
return fmt.Errorf("gitea resolver: %s: repository not accessible (HTTP %d)", archiveURL, code)
}
},
}
start := time.Now()
_, err := r.Fetch(context.Background(), "owner/repo#main", t.TempDir())
if time.Since(start) > 2*time.Second {
t.Errorf("Fetch took too long (%s); should fast-fail", time.Since(start))
}
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), tok) {
t.Errorf("PAT leaked into error: %v", err)
}
if code == http.StatusNotFound {
if !errors.Is(err, ErrPluginNotFound) {
t.Errorf("expected ErrPluginNotFound for 404, got %v", err)
}
} else {
if errors.Is(err, ErrPluginNotFound) {
t.Errorf("did not expect ErrPluginNotFound for %d", code)
}
}
})
}
}
func TestGiteaResolver_ArchiveFetch_Timeout(t *testing.T) {
t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", "tok")
r := &GiteaResolver{
BaseURL: "https://git.example.com",
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
FetchTimeout: 500 * time.Millisecond,
ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Minute):
return errors.New("should have been cancelled")
}
},
}
start := time.Now()
_, err := r.Fetch(context.Background(), "owner/repo#main", t.TempDir())
if time.Since(start) > 2*time.Second {
t.Errorf("Fetch took too long (%s); timeout should fire around 500ms", time.Since(start))
}
if err == nil {
t.Fatal("expected timeout error")
}
if !strings.Contains(err.Error(), "timed out") && !strings.Contains(err.Error(), "deadline exceeded") {
t.Errorf("expected timeout/deadline error, got %v", err)
}
}
func TestGiteaResolver_ArchiveFetch_Success(t *testing.T) {
const tok = "SUPERSECRET-ARCHIVE-TOKEN-777"
t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", tok)
archive := makePluginTarball(t, "repo", map[string]string{
"plugin.yaml": "name: repo\nversion: 1.0.0\n",
"README.md": "# repo",
})
const wantSHA = "abc123def456abc123def456abc123def456abcd"
commitsHandler := func(w http.ResponseWriter, req *http.Request) {
if auth := req.Header.Get("Authorization"); auth != "token "+tok {
t.Errorf("commits request auth header = %q, want token %s", auth, tok)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "/commits") {
commitsHandler(w, req)
return
}
http.NotFound(w, req)
}))
defer server.Close()
r := &GiteaResolver{
BaseURL: server.URL,
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
ResolveRefClient: server.Client(),
ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error {
if token != tok {
t.Errorf("token not passed to downloader: got %q", token)
}
writeTarball(t, dstDir, archive)
return nil
},
}
dst := t.TempDir()
name, err := r.Fetch(context.Background(), "owner/repo#main", dst)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if name != "repo" {
t.Errorf("plugin name = %q, want repo", name)
}
for _, want := range []string{"plugin.yaml", "README.md"} {
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
t.Errorf("expected %q in dst: %v", want, err)
}
}
if r.LastSHA() != wantSHA {
t.Errorf("LastSHA = %q, want %q", r.LastSHA(), wantSHA)
}
}
func TestGiteaResolver_ArchiveFetch_Subpath(t *testing.T) {
const wantSHA = "abc123def456abc123def456abc123def456abcd"
archive := makePluginTarball(t, "template", map[string]string{
"agent-skills/seo-all/plugin.yaml": "name: seo-all\n",
"agent-skills/seo-all/SKILL.md": "# SEO",
"config.yaml": "name: template",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "/commits") {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA)
return
}
http.NotFound(w, req)
}))
defer server.Close()
r := &GiteaResolver{
BaseURL: server.URL,
ResolveRefClient: server.Client(),
ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error {
writeTarball(t, dstDir, archive)
return nil
},
}
dst := t.TempDir()
name, err := r.Fetch(context.Background(), "owner/template/agent-skills/seo-all#main", dst)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if name != "seo-all" {
t.Errorf("plugin name = %q, want seo-all", name)
}
for _, want := range []string{"plugin.yaml", "SKILL.md"} {
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
t.Errorf("expected %q in dst: %v", want, err)
}
}
for _, notWant := range []string{"config.yaml", "agent-skills"} {
if _, err := os.Stat(filepath.Join(dst, notWant)); !os.IsNotExist(err) {
t.Errorf("subpath isolation violated: %q leaked", notWant)
}
}
if r.LastSHA() != wantSHA {
t.Errorf("LastSHA = %q, want %q", r.LastSHA(), wantSHA)
}
}
func TestGiteaResolver_ArchiveFetch_ResolveRef(t *testing.T) {
const wantSHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if auth := req.Header.Get("Authorization"); auth != "token the-token" {
t.Errorf("auth = %q, want token the-token", auth)
}
if strings.HasSuffix(req.URL.Path, "/commits") {
q := req.URL.Query()
if got := q.Get("sha"); got != "main" {
t.Errorf("sha query = %q, want main", got)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA)
return
}
http.NotFound(w, req)
}))
defer server.Close()
t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", "the-token")
r := &GiteaResolver{
BaseURL: server.URL,
TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN",
ResolveRefClient: server.Client(),
}
sha, err := r.ResolveRef(context.Background(), "owner/repo#main")
if err != nil {
t.Fatalf("ResolveRef: %v", err)
}
if sha != wantSHA {
t.Errorf("ResolveRef = %q, want %q", sha, wantSHA)
}
}
func TestGiteaResolver_ArchiveFetch_ResolveRef_TagPrefix(t *testing.T) {
const wantSHA = "1111111111111111111111111111111111111111"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "/commits") {
q := req.URL.Query()
if got := q.Get("sha"); got != "v1.2.0" {
t.Errorf("sha query = %q, want v1.2.0", got)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA)
return
}
http.NotFound(w, req)
}))
defer server.Close()
r := &GiteaResolver{
BaseURL: server.URL,
ResolveRefClient: server.Client(),
}
sha, err := r.ResolveRef(context.Background(), "owner/repo#tag:v1.2.0")
if err != nil {
t.Fatalf("ResolveRef: %v", err)
}
if sha != wantSHA {
t.Errorf("ResolveRef = %q, want %q", sha, wantSHA)
}
}